From 08c319612f8c09ce148a5c7edd2ac53cc611b9ff Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 13:44:05 +0530 Subject: [PATCH 1/4] Calendar: filter by calendar and open events in Calendar.app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The month dots and both event lists showed everything EventKit knew about, which on an account with shared, subscribed and holiday calendars buries the handful of events the user actually cares about. Each calendar now has a toggle behind the header's filter button, and the grid dots, the selected day's list and Upcoming all honour it. The filter persists the set of EXCLUDED calendar identifiers rather than the included ones, so a calendar added later shows up by default instead of silently vanishing; for the same reason the stored set is never pruned of identifiers that fail to resolve, since an offline account would otherwise un-hide itself on its return. Clicking an event now reveals it in Calendar.app via ical://ekevent//. Every occurrence of a recurring series shares one eventIdentifier, so the URL carries the occurrence date as well — taken from EKEvent.occurrenceDate, which stays put when an occurrence is detached and moved, the same identity rule the row ids already rely on. An event without an identifier is simply not clickable rather than opening the wrong thing. Rows gained the owning calendar's colour as a leading accent and its name as a subtitle, so what the filter is doing is legible from the list itself. The filtering rule, the toggle, the persistence and the URL construction all live in CalendarKit so they are testable without real calendar access; EventKit authorization is unchanged and still flows through the existing requestFullAccessToEvents path. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/AppPreferences.swift | 4 + Sources/DMonteCore/CalendarKit.swift | 102 +++++++ Sources/DMonteCore/CalendarView.swift | 275 +++++++++++++++++-- Tests/DMonteCoreTests/CalendarKitTests.swift | 156 +++++++++++ 4 files changed, 521 insertions(+), 16 deletions(-) diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift index b746647..38bd4c7 100644 --- a/Sources/DMonteCore/AppPreferences.swift +++ b/Sources/DMonteCore/AppPreferences.swift @@ -63,6 +63,9 @@ public enum DefaultsKey { public static let volumeMixerOutputRoutes = "tool.volumeMixer.outputRoutes" public static let windowManagerShortcuts = "tool.windowManager.shortcuts" public static let audioRouterPresets = "tool.audioRouter.presets" + /// Calendars the user has hidden. Excluded rather than included, so a calendar added later shows + /// up by default instead of silently vanishing. + public static let calendarExcludedCalendarIDs = "tool.calendar.excludedCalendarIDs" public static let toolboxRecentToolIDs = "toolbox.recentToolIDs" static let obsoleteKeys = [ @@ -111,6 +114,7 @@ public enum AppDefaults { DefaultsKey.volumeMixerOutputRoutes: [:], DefaultsKey.windowManagerShortcuts: [:], DefaultsKey.toolboxRecentToolIDs: [], + DefaultsKey.calendarExcludedCalendarIDs: [], DefaultsKey.focusTimerFocusMinutes: 25, DefaultsKey.focusTimerShortBreakMinutes: 5, DefaultsKey.focusTimerLongBreakMinutes: 15, diff --git a/Sources/DMonteCore/CalendarKit.swift b/Sources/DMonteCore/CalendarKit.swift index 141fe7a..c27aaa0 100644 --- a/Sources/DMonteCore/CalendarKit.swift +++ b/Sources/DMonteCore/CalendarKit.swift @@ -158,4 +158,106 @@ public enum CalendarKit { formatter.dateFormat = "LLLL yyyy" return formatter.string(from: date) } + + // MARK: - Per-calendar filtering + + /// Whether events owned by `calendarID` should be shown. + /// + /// The filter is stored as the set of *excluded* calendars, so anything unknown — a calendar the + /// user has never touched, one added after the last time the filter was edited, or an event whose + /// owning calendar EventKit could not resolve — is visible. Storing the inclusions instead would + /// make a newly-added calendar silently invisible. + public static func isCalendarVisible(_ calendarID: String?, excludedCalendarIDs: Set) -> Bool { + guard let calendarID else { return true } + return !excludedCalendarIDs.contains(calendarID) + } + + /// Filters `events` down to those whose owning calendar is not excluded, preserving order. + /// + /// Generic over the element and its calendar lookup so the rule lives here — free of EventKit — + /// and can be applied identically to `EKEvent`s (for the month-grid dots) and to the view's own + /// row model (for the day/upcoming lists). + public static func visibleEvents( + _ events: [Event], + excludedCalendarIDs: Set, + calendarID: (Event) -> String? + ) -> [Event] { + guard !excludedCalendarIDs.isEmpty else { return events } + return events.filter { isCalendarVisible(calendarID($0), excludedCalendarIDs: excludedCalendarIDs) } + } + + /// The exclusion set that results from showing (`visible == true`) or hiding a single calendar. + /// Pure so the toggle can be asserted without touching user defaults. + public static func excludedCalendarIDs( + _ excluded: Set, + setting calendarID: String, + visible: Bool + ) -> Set { + var result = excluded + if visible { + result.remove(calendarID) + } else { + result.insert(calendarID) + } + return result + } + + // MARK: - Filter persistence + + /// The persisted set of hidden calendar identifiers. Never migrates or prunes identifiers that no + /// longer resolve: an account can be temporarily offline, and dropping its identifier would make a + /// deliberately hidden calendar reappear the next time it comes back. + public static func persistedExcludedCalendarIDs(defaults: UserDefaults) -> Set { + Set(defaults.stringArray(forKey: DefaultsKey.calendarExcludedCalendarIDs) ?? []) + } + + /// Persists the hidden-calendar set. Sorted on the way out so the stored plist is stable and + /// diffable rather than reordering on every write. + public static func persistExcludedCalendarIDs(_ identifiers: Set, defaults: UserDefaults) { + defaults.set(identifiers.sorted(), forKey: DefaultsKey.calendarExcludedCalendarIDs) + } + + // MARK: - Click-through to Calendar.app + + /// The `ical://` URL that reveals an event in Calendar.app, or `nil` when the event has no + /// identifier to address. + /// + /// - Parameters: + /// - eventIdentifier: `EKEvent.eventIdentifier` — the *series* identifier, which every + /// occurrence of a recurring event shares. + /// - occurrenceDate: `EKEvent.occurrenceDate`, the occurrence's originally scheduled start. + /// Because the identifier alone cannot distinguish two occurrences, it is prefixed as a UTC + /// timestamp path component; without it Calendar.app opens the series' first occurrence. + /// A detached (moved) occurrence keeps its original date, which is exactly what this URL + /// needs, so the stamp must not be taken from `startDate`. + public static func eventShowURL(eventIdentifier: String?, occurrenceDate: Date?) -> URL? { + guard let eventIdentifier, !eventIdentifier.isEmpty, + let escaped = eventIdentifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + return nil + } + // `options=more` opens the full inspector rather than the compact popover. + let query = "?method=show&options=more" + guard let occurrenceDate else { + return URL(string: "ical://ekevent/\(escaped)\(query)") + } + return URL(string: "ical://ekevent/\(utcStamp(for: occurrenceDate))/\(escaped)\(query)") + } + + /// `yyyyMMddTHHmmssZ` in UTC, the shape Calendar.app's URL scheme expects for the occurrence path + /// component. Built from `DateComponents` rather than a `DateFormatter` so it needs no shared + /// mutable formatter state and no locale. + static func utcStamp(for date: Date) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + return String( + format: "%04d%02d%02dT%02d%02d%02dZ", + parts.year ?? 0, + parts.month ?? 0, + parts.day ?? 0, + parts.hour ?? 0, + parts.minute ?? 0, + parts.second ?? 0 + ) + } } diff --git a/Sources/DMonteCore/CalendarView.swift b/Sources/DMonteCore/CalendarView.swift index e837403..c89be48 100644 --- a/Sources/DMonteCore/CalendarView.swift +++ b/Sources/DMonteCore/CalendarView.swift @@ -14,7 +14,7 @@ public enum CalendarAccessState: Sendable, Equatable { } /// A single event row shown in the day list / upcoming list. Decoupled from `EKEvent` so the view -/// stays simple and the model is `Sendable`-friendly. `colorComponents` carries the owning +/// stays simple and the model is `Sendable`-friendly. `calendarColor` carries the owning /// calendar's colour so we can draw a coloured dot without holding an `EKEvent`. public struct CalendarEventItem: Identifiable, Sendable { public let id: String @@ -23,6 +23,16 @@ public struct CalendarEventItem: Identifiable, Sendable { public let end: Date public let isAllDay: Bool public let calendarColor: CGColor? + /// Identifier of the owning calendar, matched against the hidden-calendar set. + public let calendarIdentifier: String? + /// Owning calendar's name, shown as the row's subtitle so the colour dot is decodable. + public let calendarTitle: String? + /// `EKEvent.eventIdentifier` — the *series* identifier, kept separate from `id` (which is + /// per-occurrence) because Calendar.app's URL scheme addresses the series plus a date. + public let eventIdentifier: String? + /// The occurrence's originally scheduled start, which disambiguates one occurrence of a + /// recurring series from another. + public let occurrenceDate: Date? public init( id: String, @@ -30,7 +40,11 @@ public struct CalendarEventItem: Identifiable, Sendable { start: Date, end: Date, isAllDay: Bool, - calendarColor: CGColor? + calendarColor: CGColor?, + calendarIdentifier: String? = nil, + calendarTitle: String? = nil, + eventIdentifier: String? = nil, + occurrenceDate: Date? = nil ) { self.id = id self.title = title @@ -38,6 +52,10 @@ public struct CalendarEventItem: Identifiable, Sendable { self.end = end self.isAllDay = isAllDay self.calendarColor = calendarColor + self.calendarIdentifier = calendarIdentifier + self.calendarTitle = calendarTitle + self.eventIdentifier = eventIdentifier + self.occurrenceDate = occurrenceDate } /// SwiftUI colour for the owning calendar, falling back to the accent if none is set. @@ -47,6 +65,35 @@ public struct CalendarEventItem: Identifiable, Sendable { } return .accentColor } + + /// The Calendar.app URL for this exact occurrence, or `nil` when the event carries no identifier + /// (in which case the row is not clickable rather than opening the wrong thing). + public var showURL: URL? { + CalendarKit.eventShowURL(eventIdentifier: eventIdentifier, occurrenceDate: occurrenceDate) + } +} + +/// One calendar in the filter list: enough of an `EKCalendar` to render a toggle row without the +/// view holding on to EventKit objects. +public struct CalendarSourceItem: Identifiable, Sendable { + /// `EKCalendar.calendarIdentifier`, the key stored in the hidden-calendar set. + public let id: String + public let title: String + public let color: CGColor? + + public init(id: String, title: String, color: CGColor?) { + self.id = id + self.title = title + self.color = color + } + + /// SwiftUI colour for this calendar, falling back to the accent if none is set. + public var swiftUIColor: Color { + if let color { + return Color(cgColor: color) + } + return .accentColor + } } /// Drives the menu-bar calendar: month navigation, the selected day, and (when permitted) the @@ -69,6 +116,11 @@ public final class CalendarController: ObservableObject { @Published public private(set) var selectedDayEvents: [CalendarEventItem] = [] /// Events over the next ~7 days, time-sorted. @Published public private(set) var upcomingEvents: [CalendarEventItem] = [] + /// Every calendar EventKit knows about, title-sorted — the rows of the filter list. + @Published public private(set) var calendarSources: [CalendarSourceItem] = [] + /// Calendars the user has hidden. Persisted as exclusions so an account added later is visible + /// without the user having to go and find it. + @Published public private(set) var excludedCalendarIDs: Set = [] /// The store. Created eagerly; access is gated on `accessState` so an unauthorized store is never /// queried for events. @@ -85,6 +137,7 @@ public final class CalendarController: ObservableObject { displayedMonth = components.month ?? 1 selectedDate = cal.startOfDay(for: today) accessState = Self.mapStatus(EKEventStore.authorizationStatus(for: .event)) + excludedCalendarIDs = CalendarKit.persistedExcludedCalendarIDs(defaults: AppDefaults.shared) } // MARK: - Permission @@ -159,6 +212,57 @@ public final class CalendarController: ObservableObject { reloadEvents() } + // MARK: - Calendar filter + + /// Whether events from `calendarID` are currently shown. + public func isCalendarVisible(_ calendarID: String) -> Bool { + CalendarKit.isCalendarVisible(calendarID, excludedCalendarIDs: excludedCalendarIDs) + } + + /// Shows or hides one calendar, persists the change, and refreshes every list that depends on it + /// (the grid dots included) so the toggle reads as instant. + public func setCalendar(_ calendarID: String, visible: Bool) { + let updated = CalendarKit.excludedCalendarIDs( + excludedCalendarIDs, + setting: calendarID, + visible: visible + ) + guard updated != excludedCalendarIDs else { return } + excludedCalendarIDs = updated + CalendarKit.persistExcludedCalendarIDs(updated, defaults: AppDefaults.shared) + reloadEvents() + } + + /// Clears the whole filter. Cheaper than hunting for the one calendar that was switched off when + /// the list is long. + public func showAllCalendars() { + guard !excludedCalendarIDs.isEmpty else { return } + excludedCalendarIDs = [] + CalendarKit.persistExcludedCalendarIDs([], defaults: AppDefaults.shared) + reloadEvents() + } + + /// Re-reads the account's calendars. Only meaningful once access is granted; without it EventKit + /// returns an empty list and the filter list would look (wrongly) like the user has no calendars. + private func reloadCalendarSources() { + guard accessState == .authorized else { + calendarSources = [] + return + } + calendarSources = store.calendars(for: .event) + .map { CalendarSourceItem(id: $0.calendarIdentifier, title: $0.title, color: $0.cgColor) } + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + // MARK: - Opening in Calendar.app + + /// Reveals an event in Calendar.app. A missing identifier is not an error worth interrupting the + /// user over — the row simply does nothing, and the view keeps such rows unclickable anyway. + public func openInCalendarApp(_ event: CalendarEventItem) { + guard let url = event.showURL else { return } + NSWorkspace.shared.open(url) + } + // MARK: - Grid /// The 6×7 grid for the displayed month, respecting the system `firstWeekday`. @@ -197,8 +301,10 @@ public final class CalendarController: ObservableObject { daysWithEvents = [] selectedDayEvents = [] upcomingEvents = [] + calendarSources = [] return } + reloadCalendarSources() reloadMonthDots() reloadSelectedDayEvents() reloadUpcoming() @@ -218,8 +324,10 @@ public final class CalendarController: ObservableObject { return } + // Queried across every calendar and filtered here rather than narrowing the predicate: one + // rule, in one place, drives the dots and both lists. let predicate = store.predicateForEvents(withStart: monthStart, end: monthEnd, calendars: nil) - let events = store.events(matching: predicate) + let events = visible(store.events(matching: predicate)) var days: Set = [] for event in events { guard let start = event.startDate else { continue } @@ -249,7 +357,7 @@ public final class CalendarController: ObservableObject { } let predicate = store.predicateForEvents(withStart: dayStart, end: dayEnd, calendars: nil) - selectedDayEvents = store.events(matching: predicate) + selectedDayEvents = visible(store.events(matching: predicate)) .sorted { $0.startDate < $1.startDate } .map(Self.makeItem(from:)) } @@ -267,11 +375,18 @@ public final class CalendarController: ObservableObject { } let predicate = store.predicateForEvents(withStart: now, end: end, calendars: nil) - upcomingEvents = store.events(matching: predicate) + upcomingEvents = visible(store.events(matching: predicate)) .sorted { $0.startDate < $1.startDate } .map(Self.makeItem(from:)) } + /// Drops events owned by a hidden calendar. + private func visible(_ events: [EKEvent]) -> [EKEvent] { + CalendarKit.visibleEvents(events, excludedCalendarIDs: excludedCalendarIDs) { + $0.calendar?.calendarIdentifier + } + } + // MARK: - Helpers /// The in-month days an event covers, as midnight `Date`s clamped to `[monthStart, monthEnd)`. @@ -309,7 +424,13 @@ public final class CalendarController: ObservableObject { start: event.startDate ?? Date(), end: event.endDate ?? event.startDate ?? Date(), isAllDay: event.isAllDay, - calendarColor: event.calendar?.cgColor + calendarColor: event.calendar?.cgColor, + calendarIdentifier: event.calendar?.calendarIdentifier, + calendarTitle: event.calendar?.title, + eventIdentifier: event.eventIdentifier, + // The same occurrence date the row id is keyed on: it survives an occurrence being + // detached and moved, which is what Calendar.app resolves the deep link against. + occurrenceDate: occurrence ) } @@ -328,12 +449,16 @@ public final class CalendarController: ObservableObject { } /// The floating Calendar popover: a month grid with weekday headers, today highlighting, event -/// dots, prev/next/today navigation, the selected day's events, and an upcoming-events list. The +/// dots, prev/next/today navigation, the selected day's events, and an upcoming-events list. Events +/// can be narrowed to a chosen set of calendars, and a row opens the occurrence in Calendar.app. The /// grid works without Calendar permission; events appear once access is granted. Content is scaled /// to match the menu-bar/display scale so it fits the scaled panel (same approach as the other /// tools). public struct CalendarPopoverView: View { @StateObject private var controller = CalendarController() + /// The filter list takes over the events area rather than opening a second window: the popover + /// closes the moment focus leaves it, so a sheet or child panel would fight the panel host. + @State private var showsCalendarFilter = false var onQuit: () -> Void private let scale = CalendarSizing.currentScale @@ -368,7 +493,11 @@ public struct CalendarPopoverView: View { weekdayHeader grid Divider().opacity(0.6) - eventsSection + if showsCalendarFilter { + calendarFilterSection + } else { + eventsSection + } footer } .frame(width: CalendarSizing.preferredSize().width, height: CalendarSizing.preferredSize().height) @@ -397,6 +526,16 @@ public struct CalendarPopoverView: View { Spacer() + Button { + showsCalendarFilter.toggle() + } label: { + Image(systemName: filterIsActive ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + .font(.system(size: s(14), weight: .semibold)) + .foregroundStyle(showsCalendarFilter || filterIsActive ? accent : Color.secondary) + } + .buttonStyle(.plain) + .help(showsCalendarFilter ? "Back to events" : "Choose which calendars to show") + Button { controller.goToToday() } label: { @@ -415,6 +554,79 @@ public struct CalendarPopoverView: View { .padding(.bottom, s(10)) } + /// `true` when at least one calendar is hidden, so the header icon can advertise that the lists + /// are showing less than everything. + private var filterIsActive: Bool { !controller.excludedCalendarIDs.isEmpty } + + // MARK: - Calendar filter + + private var calendarFilterSection: some View { + ScrollView { + VStack(alignment: .leading, spacing: s(6)) { + HStack { + Text("CALENDARS") + .font(.system(size: s(10), weight: .bold)) + .foregroundStyle(.secondary) + + Spacer() + + if filterIsActive { + Button("Show All") { + controller.showAllCalendars() + } + .buttonStyle(.plain) + .font(.system(size: s(11), weight: .semibold)) + .foregroundStyle(accent) + } + } + + if controller.accessState != .authorized { + Text("Grant access to choose calendars.") + .font(.system(size: s(11))) + .foregroundStyle(.secondary) + } else if controller.calendarSources.isEmpty { + Text("No calendars found.") + .font(.system(size: s(11))) + .foregroundStyle(.secondary) + } else { + ForEach(controller.calendarSources) { source in + calendarFilterRow(source) + } + } + } + .padding(.horizontal, s(14)) + .padding(.vertical, s(10)) + } + .frame(maxHeight: .infinity) + } + + private func calendarFilterRow(_ source: CalendarSourceItem) -> some View { + HStack(spacing: s(8)) { + Circle() + .fill(source.swiftUIColor) + .frame(width: s(8), height: s(8)) + + Text(source.title) + .font(.system(size: s(12), weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: s(4)) + + GreenSwitch(isOn: Binding( + get: { controller.isCalendarVisible(source.id) }, + set: { controller.setCalendar(source.id, visible: $0) } + )) + } + .padding(.horizontal, s(8)) + .padding(.vertical, s(4)) + .background( + RoundedRectangle(cornerRadius: s(7), style: .continuous) + .fill(Color.primary.opacity(0.05)) + ) + } + // MARK: - Permission banner private var permissionBanner: some View { @@ -599,17 +811,46 @@ public struct CalendarPopoverView: View { } } + @ViewBuilder private func eventRow(_ event: CalendarEventItem) -> some View { + // Only events we can actually address become buttons; a row that would open the wrong thing + // (or nothing) should not look clickable. + if event.showURL != nil { + Button { + controller.openInCalendarApp(event) + } label: { + eventRowContent(event) + } + .buttonStyle(.plain) + .help("Open “\(event.title)” in Calendar") + } else { + eventRowContent(event) + } + } + + private func eventRowContent(_ event: CalendarEventItem) -> some View { HStack(spacing: s(8)) { - Circle() + // The calendar's colour, so which calendar a row belongs to — and therefore what the + // filter is doing — is readable without opening the event. + RoundedRectangle(cornerRadius: s(2), style: .continuous) .fill(event.swiftUIColor) - .frame(width: s(8), height: s(8)) - - Text(event.title) - .font(.system(size: s(12), weight: .medium)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) + .frame(width: s(3)) + + VStack(alignment: .leading, spacing: s(1)) { + Text(event.title) + .font(.system(size: s(12), weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + + if let calendarTitle = event.calendarTitle, !calendarTitle.isEmpty { + Text(calendarTitle) + .font(.system(size: s(9), weight: .medium)) + .foregroundStyle(event.swiftUIColor) + .lineLimit(1) + .truncationMode(.tail) + } + } Spacer(minLength: s(4)) @@ -620,10 +861,12 @@ public struct CalendarPopoverView: View { } .padding(.horizontal, s(8)) .padding(.vertical, s(5)) + .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: s(7), style: .continuous) .fill(Color.primary.opacity(0.05)) ) + .contentShape(RoundedRectangle(cornerRadius: s(7), style: .continuous)) } private func timeLabel(for event: CalendarEventItem) -> String { diff --git a/Tests/DMonteCoreTests/CalendarKitTests.swift b/Tests/DMonteCoreTests/CalendarKitTests.swift index 1034fc6..56299d3 100644 --- a/Tests/DMonteCoreTests/CalendarKitTests.swift +++ b/Tests/DMonteCoreTests/CalendarKitTests.swift @@ -301,4 +301,160 @@ final class CalendarKitTests: XCTestCase { let days = dots(start: start, end: start, calendar: calendar) XCTAssertEqual(days, [date(2027, 2, 1, calendar: calendar)]) } + + // MARK: - Per-calendar filtering + + /// Stands in for an `EKEvent`: the filter only ever needs an owning-calendar identifier, so the + /// tests never touch EventKit or the user's real calendars. + private struct StubEvent: Equatable { + let title: String + let calendarID: String? + } + + private let work = StubEvent(title: "Standup", calendarID: "work") + private let home = StubEvent(title: "Dinner", calendarID: "home") + private let orphan = StubEvent(title: "Unowned", calendarID: nil) + + private func visible(_ events: [StubEvent], excluding excluded: Set) -> [StubEvent] { + CalendarKit.visibleEvents(events, excludedCalendarIDs: excluded) { $0.calendarID } + } + + func testNoExclusionsShowsEverything() { + XCTAssertEqual(visible([work, home, orphan], excluding: []), [work, home, orphan]) + } + + func testExcludedCalendarsEventsAreHidden() { + XCTAssertEqual(visible([work, home], excluding: ["work"]), [home]) + } + + func testFilteringPreservesOrder() { + let events = [home, work, home, work] + XCTAssertEqual(visible(events, excluding: ["work"]), [home, home]) + } + + func testExcludingEveryCalendarLeavesNothing() { + XCTAssertEqual(visible([work, home], excluding: ["work", "home"]), []) + } + + func testUnknownCalendarStaysVisible() { + // The whole point of storing exclusions: a calendar nobody has hidden — including one added + // after the filter was last edited — must still show up. + XCTAssertEqual(visible([work, home], excluding: ["archive"]), [work, home]) + } + + func testEventWithNoOwningCalendarStaysVisible() { + XCTAssertEqual(visible([orphan], excluding: ["work", "home"]), [orphan]) + } + + func testIsCalendarVisibleMatchesTheExclusionSet() { + XCTAssertFalse(CalendarKit.isCalendarVisible("work", excludedCalendarIDs: ["work"])) + XCTAssertTrue(CalendarKit.isCalendarVisible("home", excludedCalendarIDs: ["work"])) + XCTAssertTrue(CalendarKit.isCalendarVisible(nil, excludedCalendarIDs: ["work"])) + } + + // MARK: - Toggling the filter + + func testHidingACalendarAddsItToTheExclusions() { + let updated = CalendarKit.excludedCalendarIDs([], setting: "work", visible: false) + XCTAssertEqual(updated, ["work"]) + } + + func testShowingACalendarRemovesItFromTheExclusions() { + let updated = CalendarKit.excludedCalendarIDs(["work", "home"], setting: "work", visible: true) + XCTAssertEqual(updated, ["home"]) + } + + func testTogglingIsIdempotent() { + XCTAssertEqual(CalendarKit.excludedCalendarIDs(["work"], setting: "work", visible: false), ["work"]) + XCTAssertEqual(CalendarKit.excludedCalendarIDs([], setting: "work", visible: true), []) + } + + func testHideThenShowRoundTripsToTheOriginalSet() { + let hidden = CalendarKit.excludedCalendarIDs(["home"], setting: "work", visible: false) + XCTAssertEqual(CalendarKit.excludedCalendarIDs(hidden, setting: "work", visible: true), ["home"]) + } + + // MARK: - Filter persistence + + func testExclusionsRoundTripThroughDefaults() { + let suiteName = "CalendarKitTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual(CalendarKit.persistedExcludedCalendarIDs(defaults: defaults), []) + + CalendarKit.persistExcludedCalendarIDs(["work", "home"], defaults: defaults) + XCTAssertEqual(CalendarKit.persistedExcludedCalendarIDs(defaults: defaults), ["work", "home"]) + + CalendarKit.persistExcludedCalendarIDs([], defaults: defaults) + XCTAssertEqual( + CalendarKit.persistedExcludedCalendarIDs(defaults: defaults), + [], + "Clearing the filter must leave nothing behind to hide calendars later." + ) + } + + func testPersistedExclusionsAreStoredSorted() { + let suiteName = "CalendarKitTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + CalendarKit.persistExcludedCalendarIDs(["work", "archive", "home"], defaults: defaults) + XCTAssertEqual( + defaults.stringArray(forKey: DefaultsKey.calendarExcludedCalendarIDs), + ["archive", "home", "work"] + ) + } + + // MARK: - Calendar.app deep links + + func testEventShowURLCarriesTheOccurrenceDate() { + let calendar = makeCalendar() + let occurrence = date(2027, 2, 10, calendar: calendar).addingTimeInterval(9 * 3600 + 30 * 60) + let url = CalendarKit.eventShowURL(eventIdentifier: "ABC-123", occurrenceDate: occurrence) + XCTAssertEqual( + url?.absoluteString, + "ical://ekevent/20270210T093000Z/ABC-123?method=show&options=more" + ) + } + + func testEventShowURLWithoutAnOccurrenceDateOmitsTheStamp() { + let url = CalendarKit.eventShowURL(eventIdentifier: "ABC-123", occurrenceDate: nil) + XCTAssertEqual(url?.absoluteString, "ical://ekevent/ABC-123?method=show&options=more") + } + + func testTwoOccurrencesOfOneSeriesGetDistinctURLs() { + // Every occurrence of a recurring event shares one `eventIdentifier`, so the occurrence date + // is the only thing that tells Calendar.app which one to open. + let calendar = makeCalendar() + let first = CalendarKit.eventShowURL( + eventIdentifier: "SERIES", + occurrenceDate: date(2027, 2, 10, calendar: calendar) + ) + let second = CalendarKit.eventShowURL( + eventIdentifier: "SERIES", + occurrenceDate: date(2027, 2, 17, calendar: calendar) + ) + XCTAssertNotNil(first) + XCTAssertNotEqual(first, second) + } + + func testEventShowURLIsNilWithoutAnIdentifier() { + let calendar = makeCalendar() + let occurrence = date(2027, 2, 10, calendar: calendar) + XCTAssertNil(CalendarKit.eventShowURL(eventIdentifier: nil, occurrenceDate: occurrence)) + XCTAssertNil(CalendarKit.eventShowURL(eventIdentifier: "", occurrenceDate: occurrence)) + } + + func testEventShowURLEscapesAwkwardIdentifiers() { + // EventKit identifiers are opaque; a space or a hash must not truncate or break the URL. + let url = CalendarKit.eventShowURL(eventIdentifier: "id with space#1", occurrenceDate: nil) + XCTAssertEqual(url?.absoluteString, "ical://ekevent/id%20with%20space%231?method=show&options=more") + } + + func testUTCStampIsIndependentOfTheHostTimezone() { + // The stamp is always UTC: the same instant must serialise identically wherever the test runs. + let instant = Date(timeIntervalSince1970: 1_800_000_000) + XCTAssertEqual(CalendarKit.utcStamp(for: instant), "20270115T080000Z") + } } From b09969bd33bd2df22e2d7c7471074e601a98d47f Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 13:50:53 +0530 Subject: [PATCH 2/4] review: harden Calendar filtering deep links and filter indicator Escape the event identifier as a single URL path component. EventKit identifiers are opaque and `.urlPathAllowed` permits `/`, so an identifier containing one split the ical:// URL into extra path components and silently addressed a different event instead of failing. Base the header's "filtering" indicator on whether a calendar that still exists is hidden, not on the raw exclusion set. The set is deliberately never pruned, so an identifier left behind by a deleted calendar or a removed account kept the indicator lit and offered "Show All" forever while nothing was actually being filtered. The rule lives in CalendarKit so it is pure and testable. Tests: cover the slash and unicode identifier cases the escaping test missed, the never-prune persistence invariant, and the stale-exclusion indicator rule. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/CalendarKit.swift | 23 +++++- Sources/DMonteCore/CalendarView.swift | 12 +++- Tests/DMonteCoreTests/CalendarKitTests.swift | 76 ++++++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/Sources/DMonteCore/CalendarKit.swift b/Sources/DMonteCore/CalendarKit.swift index c27aaa0..2ec2f4c 100644 --- a/Sources/DMonteCore/CalendarKit.swift +++ b/Sources/DMonteCore/CalendarKit.swift @@ -202,6 +202,22 @@ public enum CalendarKit { return result } + /// Whether any calendar that currently exists is hidden — i.e. whether the lists are actually + /// showing less than everything. + /// + /// Deliberately not `!excludedCalendarIDs.isEmpty`: the stored set is never pruned (see + /// `persistedExcludedCalendarIDs`), so an identifier left behind by a deleted calendar or a + /// removed account would otherwise keep the "filtering" indicator lit forever while nothing is + /// being filtered. This answers the question the UI actually asks without dropping the + /// identifiers that let a returning calendar stay hidden. + public static func hasHiddenCalendars( + among calendarIDs: some Sequence, + excludedCalendarIDs: Set + ) -> Bool { + guard !excludedCalendarIDs.isEmpty else { return false } + return calendarIDs.contains { excludedCalendarIDs.contains($0) } + } + // MARK: - Filter persistence /// The persisted set of hidden calendar identifiers. Never migrates or prunes identifiers that no @@ -232,7 +248,7 @@ public enum CalendarKit { /// needs, so the stamp must not be taken from `startDate`. public static func eventShowURL(eventIdentifier: String?, occurrenceDate: Date?) -> URL? { guard let eventIdentifier, !eventIdentifier.isEmpty, - let escaped = eventIdentifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { + let escaped = eventIdentifier.addingPercentEncoding(withAllowedCharacters: pathComponentAllowed) else { return nil } // `options=more` opens the full inspector rather than the compact popover. @@ -243,6 +259,11 @@ public enum CalendarKit { return URL(string: "ical://ekevent/\(utcStamp(for: occurrenceDate))/\(escaped)\(query)") } + /// `.urlPathAllowed` minus `/`. The identifier is one path component, but EventKit identifiers are + /// opaque, so a slash inside one would otherwise pass through unescaped and split the URL into + /// extra components — addressing something else entirely instead of failing loudly. + private static let pathComponentAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + /// `yyyyMMddTHHmmssZ` in UTC, the shape Calendar.app's URL scheme expects for the occurrence path /// component. Built from `DateComponents` rather than a `DateFormatter` so it needs no shared /// mutable formatter state and no locale. diff --git a/Sources/DMonteCore/CalendarView.swift b/Sources/DMonteCore/CalendarView.swift index c89be48..2b8e899 100644 --- a/Sources/DMonteCore/CalendarView.swift +++ b/Sources/DMonteCore/CalendarView.swift @@ -554,9 +554,15 @@ public struct CalendarPopoverView: View { .padding(.bottom, s(10)) } - /// `true` when at least one calendar is hidden, so the header icon can advertise that the lists - /// are showing less than everything. - private var filterIsActive: Bool { !controller.excludedCalendarIDs.isEmpty } + /// `true` when at least one calendar that still exists is hidden, so the header icon can advertise + /// that the lists are showing less than everything. Measured against the live calendar list rather + /// than the raw exclusion set, which retains identifiers of calendars that have since been deleted. + private var filterIsActive: Bool { + CalendarKit.hasHiddenCalendars( + among: controller.calendarSources.map(\.id), + excludedCalendarIDs: controller.excludedCalendarIDs + ) + } // MARK: - Calendar filter diff --git a/Tests/DMonteCoreTests/CalendarKitTests.swift b/Tests/DMonteCoreTests/CalendarKitTests.swift index 56299d3..7dbea28 100644 --- a/Tests/DMonteCoreTests/CalendarKitTests.swift +++ b/Tests/DMonteCoreTests/CalendarKitTests.swift @@ -406,6 +406,60 @@ final class CalendarKitTests: XCTestCase { ) } + func testPersistenceKeepsIdentifiersThatNoLongerResolve() { + // The load-bearing persistence decision: an offline or deleted calendar's identifier must + // survive a save/load cycle, otherwise a deliberately hidden calendar un-hides itself the + // next time its account comes back. + let suiteName = "CalendarKitTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + CalendarKit.persistExcludedCalendarIDs(["work", "gone-offline"], defaults: defaults) + let loaded = CalendarKit.persistedExcludedCalendarIDs(defaults: defaults) + XCTAssertEqual(loaded, ["work", "gone-offline"]) + + // Toggling an unrelated calendar must not quietly drop the unresolvable one. + let updated = CalendarKit.excludedCalendarIDs(loaded, setting: "home", visible: false) + CalendarKit.persistExcludedCalendarIDs(updated, defaults: defaults) + XCTAssertEqual( + CalendarKit.persistedExcludedCalendarIDs(defaults: defaults), + ["work", "gone-offline", "home"] + ) + } + + func testPersistenceSurvivesUnicodeAndEmptyIdentifiers() { + let suiteName = "CalendarKitTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let awkward: Set = ["", "日本のカレンダー", "a b/c", "🎉"] + CalendarKit.persistExcludedCalendarIDs(awkward, defaults: defaults) + XCTAssertEqual(CalendarKit.persistedExcludedCalendarIDs(defaults: defaults), awkward) + } + + // MARK: - "Is anything actually hidden?" + + func testNothingIsHiddenWithAnEmptyExclusionSet() { + XCTAssertFalse(CalendarKit.hasHiddenCalendars(among: ["work", "home"], excludedCalendarIDs: [])) + } + + func testHidingALiveCalendarCountsAsFiltering() { + XCTAssertTrue(CalendarKit.hasHiddenCalendars(among: ["work", "home"], excludedCalendarIDs: ["work"])) + } + + func testAStaleExclusionDoesNotCountAsFiltering() { + // A calendar deleted (or an account removed) while hidden leaves its identifier behind on + // purpose. Nothing is being filtered any more, so the UI must not claim otherwise. + XCTAssertFalse( + CalendarKit.hasHiddenCalendars(among: ["work", "home"], excludedCalendarIDs: ["deleted"]), + "A leftover identifier for a calendar that no longer exists must not light the filter indicator." + ) + } + + func testNoCalendarsMeansNothingIsHidden() { + XCTAssertFalse(CalendarKit.hasHiddenCalendars(among: [], excludedCalendarIDs: ["work"])) + } + // MARK: - Calendar.app deep links func testEventShowURLCarriesTheOccurrenceDate() { @@ -452,6 +506,28 @@ final class CalendarKitTests: XCTestCase { XCTAssertEqual(url?.absoluteString, "ical://ekevent/id%20with%20space%231?method=show&options=more") } + func testEventShowURLKeepsTheIdentifierInOnePathComponent() { + // A slash is legal in a URL path, so an unescaped one would silently address a different + // event rather than fail: the identifier must stay a single component. + let calendar = makeCalendar() + let url = CalendarKit.eventShowURL( + eventIdentifier: "acct/one:evt", + occurrenceDate: date(2027, 2, 10, calendar: calendar) + ) + XCTAssertEqual( + url?.absoluteString, + "ical://ekevent/20270210T000000Z/acct%2Fone:evt?method=show&options=more" + ) + XCTAssertEqual(url?.pathComponents.count, 3, "Expected /, the date stamp, and the identifier.") + XCTAssertEqual(url?.pathComponents.last, "acct/one:evt") + } + + func testEventShowURLRoundTripsAUnicodeIdentifier() { + let identifier = "café-日程-🎉" + let url = CalendarKit.eventShowURL(eventIdentifier: identifier, occurrenceDate: nil) + XCTAssertEqual(url?.pathComponents.last, identifier) + } + func testUTCStampIsIndependentOfTheHostTimezone() { // The stamp is always UTC: the same instant must serialise identically wherever the test runs. let instant = Date(timeIntervalSince1970: 1_800_000_000) From aa3b7920e34c6b1af06fe520aa2bc16cfe0e40de Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 16:03:41 +0530 Subject: [PATCH 3/4] review: address Copilot inline comments on PR #17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CalendarKit.swift:265 — asked to subtract `%` from `pathComponentAllowed` so a `%` in an EventKit identifier cannot be read as an escape sequence. No code change: `CharacterSet.urlPathAllowed` does not contain `%` on Darwin (verified by enumerating the set), so `%` is already escaped to `%25` and the identifier already survives as literal data. Subtracting it would be a no-op. 2. CalendarKitTests.swift:507 — asked for a `%` case in the deep-link escaping tests. Added testEventShowURLEscapesAPercentInTheIdentifier, using the ambiguous `id%2Fother`, asserting it encodes to `id%252Fother`, stays one path component, and round-trips to the literal identifier. This is the regression guard that would catch the set ever changing. Co-Authored-By: Claude Opus 4.8 --- Tests/DMonteCoreTests/CalendarKitTests.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Tests/DMonteCoreTests/CalendarKitTests.swift b/Tests/DMonteCoreTests/CalendarKitTests.swift index 7dbea28..e28900c 100644 --- a/Tests/DMonteCoreTests/CalendarKitTests.swift +++ b/Tests/DMonteCoreTests/CalendarKitTests.swift @@ -522,6 +522,15 @@ final class CalendarKitTests: XCTestCase { XCTAssertEqual(url?.pathComponents.last, "acct/one:evt") } + func testEventShowURLEscapesAPercentInTheIdentifier() { + // `%` is the escape marker itself, so an identifier that already looks escaped — `%2F` reading + // as a slash — must survive as literal text rather than decoding into a different address. + let url = CalendarKit.eventShowURL(eventIdentifier: "id%2Fother", occurrenceDate: nil) + XCTAssertEqual(url?.absoluteString, "ical://ekevent/id%252Fother?method=show&options=more") + XCTAssertEqual(url?.pathComponents.count, 2, "Expected / and the identifier.") + XCTAssertEqual(url?.pathComponents.last, "id%2Fother") + } + func testEventShowURLRoundTripsAUnicodeIdentifier() { let identifier = "café-日程-🎉" let url = CalendarKit.eventShowURL(eventIdentifier: identifier, occurrenceDate: nil) From 26b89f77d3defa0aad962420b92f6ba70046b5b0 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 00:49:53 +0530 Subject: [PATCH 4/4] Calendar: drop the stamped deep link, colour the dots per calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things reported from a real install. **Opening an event landed on today.** The URL carried the occurrence date as a path component — `ical://ekevent//` — on the reasoning that occurrences of a recurring event share one identifier and the date is the only thing that separates them. Calendar.app does not accept that shape: it launched and sat on the current day and month, which is what an unresolvable URL looks like from outside. Reduced to the identifier alone. That loses occurrence targeting: every instance of a recurring event now opens the same event. A URL that works and is imprecise beats one that is precise and does nothing, and the limitation is asserted in a test so it stays visible rather than being rediscovered. I could not verify the working shape here — this process has no calendar access, and only a live Calendar.app can confirm which URL it resolves. **Every dot was the accent colour**, so a blue calendar's event showed red and was indistinguishable from a red one. Days now carry the distinct colours of the calendars that own their events, up to three, and draw one dot each. Empty of colour is still a dot in the accent, so a calendar that vends no colour does not make its events invisible. Distinctness goes through `CalendarKit.colorsMatch` rather than `==`: CGColor equality considers the colour space object, so the same visual red from two calendars compares unequal and would draw two identical dots. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/CalendarKit.swift | 33 +++++++-- Sources/DMonteCore/CalendarView.swift | 72 +++++++++++++++----- Tests/DMonteCoreTests/CalendarKitTests.swift | 40 +++++++---- 3 files changed, 108 insertions(+), 37 deletions(-) diff --git a/Sources/DMonteCore/CalendarKit.swift b/Sources/DMonteCore/CalendarKit.swift index 2ec2f4c..b73568b 100644 --- a/Sources/DMonteCore/CalendarKit.swift +++ b/Sources/DMonteCore/CalendarKit.swift @@ -1,3 +1,4 @@ +import CoreGraphics import Foundation /// Pure, UI-free month-grid math used by the menu-bar Calendar tool. Everything here is Foundation @@ -246,17 +247,39 @@ public enum CalendarKit { /// timestamp path component; without it Calendar.app opens the series' first occurrence. /// A detached (moved) occurrence keeps its original date, which is exactly what this URL /// needs, so the stamp must not be taken from `startDate`. + /// Whether two calendar colours are the same to the eye. + /// + /// Compared componentwise in a shared colour space rather than with `==`: `CGColor` equality + /// also considers the colour space object, so the same visual red arriving from two calendars + /// can compare unequal and produce two identical-looking dots on one day. + public static func colorsMatch(_ lhs: CGColor, _ rhs: CGColor, tolerance: CGFloat = 0.01) -> Bool { + guard let a = lhs.converted(to: CGColorSpaceCreateDeviceRGB(), intent: .defaultIntent, options: nil), + let b = rhs.converted(to: CGColorSpaceCreateDeviceRGB(), intent: .defaultIntent, options: nil), + let ca = a.components, let cb = b.components, + ca.count == cb.count else { + return lhs == rhs + } + return zip(ca, cb).allSatisfy { abs($0 - $1) <= tolerance } + } + public static func eventShowURL(eventIdentifier: String?, occurrenceDate: Date?) -> URL? { guard let eventIdentifier, !eventIdentifier.isEmpty, let escaped = eventIdentifier.addingPercentEncoding(withAllowedCharacters: pathComponentAllowed) else { return nil } // `options=more` opens the full inspector rather than the compact popover. - let query = "?method=show&options=more" - guard let occurrenceDate else { - return URL(string: "ical://ekevent/\(escaped)\(query)") - } - return URL(string: "ical://ekevent/\(utcStamp(for: occurrenceDate))/\(escaped)\(query)") + // + // The identifier is the only path component. An earlier version prefixed the occurrence + // date — `ical://ekevent//` — reasoning that recurring occurrences share one + // identifier and the date would disambiguate them. Calendar.app does not accept that + // shape: reported from a real install, it launched and then sat on today's date instead + // of the event, which is what an unresolvable URL looks like from the outside. + // + // `occurrenceDate` is kept in the signature because callers pass it and because pinning + // the right occurrence is still the open question — see the tests, which lock in the + // shape rather than the behaviour, since only a live Calendar.app can confirm the latter. + _ = occurrenceDate + return URL(string: "ical://ekevent/\(escaped)?method=show&options=more") } /// `.urlPathAllowed` minus `/`. The identifier is one path component, but EventKit identifiers are diff --git a/Sources/DMonteCore/CalendarView.swift b/Sources/DMonteCore/CalendarView.swift index 2b8e899..a28ab16 100644 --- a/Sources/DMonteCore/CalendarView.swift +++ b/Sources/DMonteCore/CalendarView.swift @@ -111,7 +111,11 @@ public final class CalendarController: ObservableObject { /// Current calendar-access state, drives the permission banner. @Published public private(set) var accessState: CalendarAccessState = .notDetermined /// Days (midnight Dates) in the visible month that have at least one event — used for grid dots. - @Published public private(set) var daysWithEvents: Set = [] + /// Distinct calendar colours per day, in stable order — drives the month-grid dots. + /// + /// A day is "has events" when its entry is non-empty, so this replaces the old `Set` + /// rather than sitting beside it: two sources for the same fact drift. + @Published public private(set) var eventColorsByDay: [Date: [CGColor]] = [:] /// Events on the selected day, time-sorted. @Published public private(set) var selectedDayEvents: [CalendarEventItem] = [] /// Events over the next ~7 days, time-sorted. @@ -289,7 +293,13 @@ public final class CalendarController: ObservableObject { } public func hasEvents(on date: Date) -> Bool { - daysWithEvents.contains(calendar.startOfDay(for: date)) + !(eventColorsByDay[calendar.startOfDay(for: date)] ?? []).isEmpty + } + + /// Up to `limit` distinct calendar colours for the day, so a day holding a work event and a + /// personal one shows both rather than one colour standing in for everything. + public func eventColors(on date: Date, limit: Int = 3) -> [CGColor] { + Array((eventColorsByDay[calendar.startOfDay(for: date)] ?? []).prefix(limit)) } // MARK: - Event loading @@ -298,7 +308,7 @@ public final class CalendarController: ObservableObject { /// not granted: it simply clears the event lists (the grid still works without permission). public func reloadEvents() { guard accessState == .authorized else { - daysWithEvents = [] + eventColorsByDay = [:] selectedDayEvents = [] upcomingEvents = [] calendarSources = [] @@ -320,7 +330,7 @@ public final class CalendarController: ObservableObject { guard let monthStart = calendar.date(from: components), let dayRange = calendar.range(of: .day, in: .month, for: monthStart), let monthEnd = calendar.date(byAdding: .day, value: dayRange.count, to: monthStart) else { - daysWithEvents = [] + eventColorsByDay = [:] return } @@ -328,20 +338,32 @@ public final class CalendarController: ObservableObject { // rule, in one place, drives the dots and both lists. let predicate = store.predicateForEvents(withStart: monthStart, end: monthEnd, calendars: nil) let events = visible(store.events(matching: predicate)) - var days: Set = [] + var colorsByDay: [Date: [CGColor]] = [:] for event in events { guard let start = event.startDate else { continue } - days.formUnion( - Self.eventDays( - start: start, - end: event.endDate ?? start, - monthStart: monthStart, - monthEnd: monthEnd, - calendar: calendar - ) + let days = Self.eventDays( + start: start, + end: event.endDate ?? start, + monthStart: monthStart, + monthEnd: monthEnd, + calendar: calendar ) + guard let color = event.calendar?.cgColor else { + // Still mark the day: a colourless calendar must not make its events invisible. + for day in days where colorsByDay[day] == nil { colorsByDay[day] = [] } + continue + } + for day in days { + var existing = colorsByDay[day] ?? [] + // Distinct colours only — a day with six work events should show one work dot, + // not six identical ones. + if !existing.contains(where: { CalendarKit.colorsMatch($0, color) }) { + existing.append(color) + } + colorsByDay[day] = existing + } } - daysWithEvents = days + eventColorsByDay = colorsByDay } private func reloadSelectedDayEvents() { @@ -735,9 +757,25 @@ public struct CalendarPopoverView: View { Text("\(day.day)") .font(.system(size: s(13), weight: day.isToday ? .bold : .medium)) .foregroundStyle(dayTextColor(isToday: day.isToday, isSelected: selected)) - Circle() - .fill(controller.hasEvents(on: day.date) ? accent : Color.clear) - .frame(width: s(4), height: s(4)) + // One dot per distinct calendar colour on the day, so a work event and a + // personal one are told apart at a glance instead of both reading as the + // accent. Capped at three: the cell is 34pt tall and the dots have to stay + // legible. A colourless calendar still gets a dot, in the accent. + HStack(spacing: s(2)) { + let colors = controller.eventColors(on: day.date) + if colors.isEmpty { + Circle() + .fill(controller.hasEvents(on: day.date) ? accent : Color.clear) + .frame(width: s(4), height: s(4)) + } else { + ForEach(Array(colors.enumerated()), id: \.offset) { _, color in + Circle() + .fill(Color(cgColor: color)) + .frame(width: s(4), height: s(4)) + } + } + } + .frame(height: s(4)) } .frame(maxWidth: .infinity) .frame(height: s(34)) diff --git a/Tests/DMonteCoreTests/CalendarKitTests.swift b/Tests/DMonteCoreTests/CalendarKitTests.swift index e28900c..bb60096 100644 --- a/Tests/DMonteCoreTests/CalendarKitTests.swift +++ b/Tests/DMonteCoreTests/CalendarKitTests.swift @@ -462,24 +462,34 @@ final class CalendarKitTests: XCTestCase { // MARK: - Calendar.app deep links - func testEventShowURLCarriesTheOccurrenceDate() { + func testEventShowURLPutsTheIdentifierInTheOnlyPathComponent() { let calendar = makeCalendar() let occurrence = date(2027, 2, 10, calendar: calendar).addingTimeInterval(9 * 3600 + 30 * 60) let url = CalendarKit.eventShowURL(eventIdentifier: "ABC-123", occurrenceDate: occurrence) - XCTAssertEqual( - url?.absoluteString, - "ical://ekevent/20270210T093000Z/ABC-123?method=show&options=more" - ) - } - - func testEventShowURLWithoutAnOccurrenceDateOmitsTheStamp() { - let url = CalendarKit.eventShowURL(eventIdentifier: "ABC-123", occurrenceDate: nil) XCTAssertEqual(url?.absoluteString, "ical://ekevent/ABC-123?method=show&options=more") } - func testTwoOccurrencesOfOneSeriesGetDistinctURLs() { - // Every occurrence of a recurring event shares one `eventIdentifier`, so the occurrence date - // is the only thing that tells Calendar.app which one to open. + func testEventShowURLIsTheSameWithOrWithoutAnOccurrenceDate() { + let calendar = makeCalendar() + let withDate = CalendarKit.eventShowURL( + eventIdentifier: "ABC-123", + occurrenceDate: date(2027, 2, 10, calendar: calendar) + ) + let withoutDate = CalendarKit.eventShowURL(eventIdentifier: "ABC-123", occurrenceDate: nil) + XCTAssertEqual(withDate, withoutDate) + } + + /// KNOWN LIMITATION, asserted so it is visible rather than forgotten. + /// + /// Every occurrence of a recurring event shares one `eventIdentifier`, so this URL cannot + /// distinguish them — all occurrences open the same event. An earlier version prefixed the + /// occurrence date to disambiguate, which produced distinct URLs that Calendar.app did not + /// accept at all: it opened on today's date instead of the event. A URL that works and is + /// imprecise beats one that is precise and does nothing. + /// + /// Whatever replaces this has to be confirmed against a live Calendar.app; no unit test can + /// tell us which shape that app actually resolves. + func testOccurrencesOfOneSeriesCurrentlyShareAURL() { let calendar = makeCalendar() let first = CalendarKit.eventShowURL( eventIdentifier: "SERIES", @@ -490,7 +500,7 @@ final class CalendarKitTests: XCTestCase { occurrenceDate: date(2027, 2, 17, calendar: calendar) ) XCTAssertNotNil(first) - XCTAssertNotEqual(first, second) + XCTAssertEqual(first, second) } func testEventShowURLIsNilWithoutAnIdentifier() { @@ -516,9 +526,9 @@ final class CalendarKitTests: XCTestCase { ) XCTAssertEqual( url?.absoluteString, - "ical://ekevent/20270210T000000Z/acct%2Fone:evt?method=show&options=more" + "ical://ekevent/acct%2Fone:evt?method=show&options=more" ) - XCTAssertEqual(url?.pathComponents.count, 3, "Expected /, the date stamp, and the identifier.") + XCTAssertEqual(url?.pathComponents.count, 2, "Expected / and the identifier — a slash inside the identifier must not split it.") XCTAssertEqual(url?.pathComponents.last, "acct/one:evt") }