diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift index 10370b0..aa9520b 100644 --- a/Sources/DMonteCore/AppPreferences.swift +++ b/Sources/DMonteCore/AppPreferences.swift @@ -69,6 +69,10 @@ public enum DefaultsKey { public static let windowManagerShortcuts = "tool.windowManager.shortcuts" public static let audioRouterPresets = "tool.audioRouter.presets" public static let qrTemplate = "tool.qr.template" + + /// 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 = [ @@ -119,6 +123,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..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 @@ -158,4 +159,149 @@ 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 + } + + /// 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 + /// 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`. + /// 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. + // + // 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 + /// 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. + 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..a28ab16 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 @@ -64,11 +111,20 @@ 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. @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 +141,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 +216,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`. @@ -185,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 @@ -194,11 +308,13 @@ 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 = [] return } + reloadCalendarSources() reloadMonthDots() reloadSelectedDayEvents() reloadUpcoming() @@ -214,26 +330,40 @@ 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 } + // 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) - var days: Set = [] + let events = visible(store.events(matching: predicate)) + 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() { @@ -249,7 +379,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 +397,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 +446,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 +471,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 +515,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 +548,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 +576,85 @@ public struct CalendarPopoverView: View { .padding(.bottom, s(10)) } + /// `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 + + 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 { @@ -517,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)) @@ -599,17 +855,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 +905,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..bb60096 100644 --- a/Tests/DMonteCoreTests/CalendarKitTests.swift +++ b/Tests/DMonteCoreTests/CalendarKitTests.swift @@ -301,4 +301,255 @@ 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"] + ) + } + + 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 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/ABC-123?method=show&options=more") + } + + 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", + occurrenceDate: date(2027, 2, 10, calendar: calendar) + ) + let second = CalendarKit.eventShowURL( + eventIdentifier: "SERIES", + occurrenceDate: date(2027, 2, 17, calendar: calendar) + ) + XCTAssertNotNil(first) + XCTAssertEqual(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 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/acct%2Fone:evt?method=show&options=more" + ) + 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") + } + + 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) + 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) + XCTAssertEqual(CalendarKit.utcStamp(for: instant), "20270115T080000Z") + } }