diff --git a/AvailabilityClick.xcodeproj/project.pbxproj b/AvailabilityClick.xcodeproj/project.pbxproj index f6ee6bf..d2defd9 100644 --- a/AvailabilityClick.xcodeproj/project.pbxproj +++ b/AvailabilityClick.xcodeproj/project.pbxproj @@ -155,7 +155,7 @@ path = Intents; sourceTree = ""; }; - "TEMP_32DE843E-175A-4E3B-BD4C-D545AA8D210F" /* .claude */ = { + "TEMP_6B525DBB-9255-4ED2-9587-337BE20C32C0" /* .claude */ = { isa = PBXGroup; children = ( ); diff --git a/AvailabilityClick/AppDelegate.swift b/AvailabilityClick/AppDelegate.swift index b2fa5d9..578d18b 100644 --- a/AvailabilityClick/AppDelegate.swift +++ b/AvailabilityClick/AppDelegate.swift @@ -144,14 +144,44 @@ final class HoldGestureMachine { } } +/// The slot-shaping settings `AvailabilityService.calculateAvailability` reads +/// live. Captured at copy time so the stale-copy recheck (R5) recomputes under +/// the SAME rules the copied slots were computed with — otherwise changing Slot +/// Rounding, a buffer, or working hours after a copy shifts the recomputed +/// boundaries and falsely badges unbooked slots as "no longer free." +/// Internal (not private) so a test can pin it. Equatable is the whole point. +struct SlotSettingsSignature: Equatable { + let workingDays: [Int] + let workingHoursStart: Int + let workingHoursEnd: Int + let todayBufferMinutes: Int + let eventBufferMinutes: Int + let minimumSlotMinutes: Int + let roundingGranularity: Int + + static var current: SlotSettingsSignature { + SlotSettingsSignature( + workingDays: AppSettings.workingDays, + workingHoursStart: AppSettings.workingHoursStart, + workingHoursEnd: AppSettings.workingHoursEnd, + todayBufferMinutes: AppSettings.todayBufferMinutes, + eventBufferMinutes: AppSettings.eventBufferMinutes, + minimumSlotMinutes: AppSettings.minimumSlotMinutes, + roundingGranularity: AppSettings.roundingGranularity + ) + } +} + /// What the app last copied, so a calendar change can be checked against it /// (R5 watch) and the next copy can detect a silent clobber (R6 guard). The /// original `now` is retained so the recompute lands on the SAME days, not a -/// window re-derived from a later clock (KTD11). +/// window re-derived from a later clock (KTD11); the settings signature keeps +/// the recompute on the SAME slot-shaping rules. private struct CopyWatch { let slots: Set let rangeType: DateRangeType let now: Date + let settings: SlotSettingsSignature } @MainActor @@ -353,7 +383,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// replaces the watched set (R5). The pasteboard `changeCount` snapshot is /// taken inside PasteboardWriter by the write itself (KTD10). private func recordCopy(slots: Set, rangeType: DateRangeType, now: Date) { - copyWatch = CopyWatch(slots: slots, rangeType: rangeType, now: now) + copyWatch = CopyWatch(slots: slots, rangeType: rangeType, now: now, settings: .current) copyWatchGeneration += 1 confirmationPending = false statusItemController.clearAttention(ifShowing: .copiedSlotStale) @@ -380,6 +410,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { calendarService.isAuthorized, !calendarService.selectedCalendars().isEmpty else { return } + // The recompute below reads slot-shaping settings live. If they changed + // since the copy, the copied snapshot and the recompute follow different + // rules and a "no longer free" comparison is meaningless — skip until the + // next copy re-snapshots under current settings. R5 is "a copied slot got + // booked," not "a setting changed." + guard SlotSettingsSignature.current == watch.settings else { return } + let generation = copyWatchGeneration let window = availabilityService.fetchWindow(for: watch.rangeType, now: watch.now) let events = await calendarService.fetchEvents(from: window.start, to: window.end) @@ -529,19 +566,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// timeout; an unauthorized/no-calendars hold shows the same outcome a copy /// would, never an empty popover. private func presentPreview(holdInitiated: Bool) { - guard calendarService.isAuthorized else { - handleUnauthorizedInteraction() - return - } + guard requireAuthorized() else { return } let rangeType = defaultRangeType Task { @MainActor in - refreshCalendarAttention() - guard !calendarService.selectedCalendars().isEmpty else { - statusItemController.showOutcome(.noCalendars) - return - } + guard ensureCalendarsAvailable() else { return } let now = Date() let dateRange = availabilityService.fetchWindow(for: rangeType, now: now) @@ -572,10 +602,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate { copyRange(defaultRangeType) } + /// The shared authorization gate: flashes `.noAccess` and drives the + /// permission request/alert when calendar access isn't granted, returning + /// false. Every user-triggered entry point — the copy prologue and the + /// preview — goes through it so the unauthorized path can't drift. + private func requireAuthorized() -> Bool { + guard calendarService.isAuthorized else { + handleUnauthorizedInteraction() + return false + } + return true + } + + /// Auth + debounce prologue shared by copyRange and copyProposal. Auth + /// precedes the debounce (OQ4) so failure feedback always fires; the + /// debounce only swallows authorized repeat clicks. Returns the copy's + /// `now`, or nil when the attempt was handled (unauthorized) or swallowed + /// (debounced) — the caller returns on nil. + private func beginCopyAttempt() -> Date? { + guard requireAuthorized() else { return nil } + let now = Date() + guard now.timeIntervalSince(lastCopyTime) > 0.5 else { return nil } + lastCopyTime = now + return now + } + + /// Refreshes the calendars-unavailable badge and reports whether any + /// calendar is selected; flashes `.noCalendars` and returns false when none + /// is. Shared by the copy and preview flows. + private func ensureCalendarsAvailable() -> Bool { + refreshCalendarAttention() + guard !calendarService.selectedCalendars().isEmpty else { + statusItemController.showOutcome(.noCalendars) + return false + } + return true + } + /// Gate ordering for the copy pipeline, extracted pure so it is /// unit-testable. Authorization precedes the debounce (OQ4): failure /// feedback always fires, the debounce only swallows authorized repeat - /// clicks (nil = ignore silently). Keep copyRange's guards in sync. + /// clicks (nil = ignore silently). This models the gate order now enforced + /// by `requireAuthorized()` + `beginCopyAttempt()` + `ensureCalendarsAvailable()` + /// — keep those helpers in sync with this order, not copyRange directly. static func copyDecision( isAuthorized: Bool, debouncePassed: Bool, @@ -590,23 +659,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } private func copyRange(_ rangeType: DateRangeType) { - // Authorization before debounce (OQ4) -- see copyDecision above. - guard calendarService.isAuthorized else { - handleUnauthorizedInteraction() - return - } - - // Debounce rapid clicks - let now = Date() - guard now.timeIntervalSince(lastCopyTime) > 0.5 else { return } - lastCopyTime = now + guard let now = beginCopyAttempt() else { return } Task { @MainActor in - refreshCalendarAttention() - guard !calendarService.selectedCalendars().isEmpty else { - statusItemController.showOutcome(.noCalendars) - return - } + guard ensureCalendarsAvailable() else { return } let dateRange = availabilityService.fetchWindow(for: rangeType, now: now) let events = await calendarService.fetchEvents(from: dateRange.start, to: dateRange.end) @@ -661,21 +717,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// over the today-inclusive 30-day window. Same auth/debounce/no-calendars /// gates as copyRange; an empty proposal is the ordinary no-slots outcome. private func copyProposal() { - guard calendarService.isAuthorized else { - handleUnauthorizedInteraction() - return - } - - let now = Date() - guard now.timeIntervalSince(lastCopyTime) > 0.5 else { return } - lastCopyTime = now + guard let now = beginCopyAttempt() else { return } Task { @MainActor in - refreshCalendarAttention() - guard !calendarService.selectedCalendars().isEmpty else { - statusItemController.showOutcome(.noCalendars) - return - } + guard ensureCalendarsAvailable() else { return } let rangeType: DateRangeType = .next30DaysIncludingToday let dateRange = availabilityService.fetchWindow(for: rangeType, now: now) diff --git a/AvailabilityClick/Intents/GetAvailabilityIntent.swift b/AvailabilityClick/Intents/GetAvailabilityIntent.swift index b5e7bed..9b35b14 100644 --- a/AvailabilityClick/Intents/GetAvailabilityIntent.swift +++ b/AvailabilityClick/Intents/GetAvailabilityIntent.swift @@ -52,6 +52,43 @@ enum GetAvailabilityError: Error, CustomLocalizedStringResourceConvertible { } } +/// Shared pipeline for both Shortcuts intents. Marks the headless launch, +/// enforces the auth and no-calendars guards, then runs the same computation +/// the click path uses and returns the post-pipeline slot dictionary — each +/// intent shapes its own result (formatted text vs structured entities). One +/// place so the two intents' guards and pipeline can never drift. +@MainActor +func resolveAvailabilitySlots(range: AvailabilityRange, businessDays: Int?) async throws -> [Date: [TimeSlot]] { + // A cold Shortcuts run launches the app headless; mark the launch so its + // user-visible side effects (TCC auto-request, first-run coach) stay + // quiet (OQ10). + AppDelegate.intentDidRunThisLaunch = true + + // Shortcuts runs are frequently unattended: never trigger the system + // permission prompt from here -- throw a descriptive error instead. + guard CalendarService.shared.isAuthorized else { + throw GetAvailabilityError.calendarAccessNotGranted + } + // An empty selection (empty store, or an all-stale selection under R11) + // throws, never a silent all-calendars read. + guard !CalendarService.shared.selectedCalendars().isEmpty else { + throw GetAvailabilityError.noCalendarsAvailable + } + + let rangeType = AvailabilityRange.dateRangeType(for: range, businessDays: businessDays) + let service = AvailabilityService() + // Capture `now` once and pass it to BOTH the window derivation and the slot + // math (the click path already does this). Otherwise each defaults to its + // own `Date()` with the EventKit fetch between them; a clock crossing + // midnight or the today-buffer cutoff mid-fetch shifts calculateAvailability's + // day list off the fetched window, and the trailing day — having no fetched + // events — reads as falsely fully free. + let now = Date() + let window = service.fetchWindow(for: rangeType, now: now) + let events = await CalendarService.shared.fetchEvents(from: window.start, to: window.end) + return service.calculateAvailability(events: events, rangeType: rangeType, now: now) +} + /// Read-only, interactive-safe (KTD6): returns the formatted availability /// string without opening UI, flashing the icon, touching the clipboard, or /// triggering the system permission prompt. @@ -78,28 +115,7 @@ struct GetAvailabilityIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue { - // A cold Shortcuts run launches the app headless; mark the launch so - // its user-visible side effects (TCC auto-request, first-run coach) - // stay quiet (OQ10). - AppDelegate.intentDidRunThisLaunch = true - - // Shortcuts runs are frequently unattended: never trigger the system - // permission prompt from here -- throw a descriptive error instead. - guard CalendarService.shared.isAuthorized else { - throw GetAvailabilityError.calendarAccessNotGranted - } - - // Mirror the click pipeline's .noCalendars outcome: an empty store - // must error, not report a fully-free week computed from zero events. - guard !CalendarService.shared.selectedCalendars().isEmpty else { - throw GetAvailabilityError.noCalendarsAvailable - } - - let rangeType = AvailabilityRange.dateRangeType(for: range, businessDays: businessDays) - let service = AvailabilityService() - let window = service.fetchWindow(for: rangeType) - let events = await CalendarService.shared.fetchEvents(from: window.start, to: window.end) - let slots = service.calculateAvailability(events: events, rangeType: rangeType) + let slots = try await resolveAvailabilitySlots(range: range, businessDays: businessDays) // Always the plain-text template: markdown syntax is unwanted // mid-automation (KTD6). diff --git a/AvailabilityClick/Intents/GetAvailabilitySlotsIntent.swift b/AvailabilityClick/Intents/GetAvailabilitySlotsIntent.swift index 7d4a57b..11f88b3 100644 --- a/AvailabilityClick/Intents/GetAvailabilitySlotsIntent.swift +++ b/AvailabilityClick/Intents/GetAvailabilitySlotsIntent.swift @@ -69,24 +69,7 @@ struct GetAvailabilitySlotsIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue<[AvailabilitySlot]> { - // Mark the launch so a cold headless run stays quiet (OQ10), mirroring - // the text intent. - AppDelegate.intentDidRunThisLaunch = true - - guard CalendarService.shared.isAuthorized else { - throw GetAvailabilityError.calendarAccessNotGranted - } - // A stale-selection or empty store throws, never a silent all-calendars - // read (R11) -- selectedCalendars() is empty in that case. - guard !CalendarService.shared.selectedCalendars().isEmpty else { - throw GetAvailabilityError.noCalendarsAvailable - } - - let rangeType = AvailabilityRange.dateRangeType(for: range, businessDays: businessDays) - let service = AvailabilityService() - let window = service.fetchWindow(for: rangeType) - let events = await CalendarService.shared.fetchEvents(from: window.start, to: window.end) - let slots = service.calculateAvailability(events: events, rangeType: rangeType) + let slots = try await resolveAvailabilitySlots(range: range, businessDays: businessDays) // Empty availability is a valid answer (a booked week), not an error; // throws are reserved for auth/no-calendars. diff --git a/AvailabilityClick/Services/AvailabilityFormatter.swift b/AvailabilityClick/Services/AvailabilityFormatter.swift index 6c9cd11..21f1376 100644 --- a/AvailabilityClick/Services/AvailabilityFormatter.swift +++ b/AvailabilityClick/Services/AvailabilityFormatter.swift @@ -53,12 +53,7 @@ struct AvailabilityFormatter { } } - if showTimeZone { - lines.append("(\(Self.timezoneString(for: timezone)))") - } - if let asOf { - lines.append(asOfLine(asOf, timezone: timezone)) - } + appendTrailingLines(to: &lines, showTimeZone: showTimeZone, timezone: timezone, asOf: asOf) return lines.joined(separator: "\n") } @@ -135,6 +130,24 @@ struct AvailabilityFormatter { return "(as of \(stamp))" } + /// Appends the optional timezone label and as-of stamp — the trailing lines + /// shared by the grid (`format`) and proposal (`formatProposal`) string + /// renderers — so their composition can't drift. The attributed renderer + /// builds the same two lines in NSAttributedString form. + private func appendTrailingLines( + to lines: inout [String], + showTimeZone: Bool, + timezone: TimeZone?, + asOf: Date? + ) { + if showTimeZone { + lines.append("(\(Self.timezoneString(for: timezone)))") + } + if let asOf { + lines.append(asOfLine(asOf, timezone: timezone)) + } + } + // MARK: - Proposal Sentence (U4) /// Renders the pre-selected proposal slots as one numbered sentence (R1/R2) @@ -167,12 +180,7 @@ struct AvailabilityFormatter { lines.append("Here are a few times that could work — reply with a number: \(items.joined(separator: "; ")).") } - if showTimeZone { - lines.append("(\(Self.timezoneString(for: timezone)))") - } - if let asOf { - lines.append(asOfLine(asOf, timezone: timezone)) - } + appendTrailingLines(to: &lines, showTimeZone: showTimeZone, timezone: timezone, asOf: asOf) return lines.joined(separator: "\n") } diff --git a/AvailabilityClick/Services/AvailabilityService.swift b/AvailabilityClick/Services/AvailabilityService.swift index bdc66d1..aa14b0d 100644 --- a/AvailabilityClick/Services/AvailabilityService.swift +++ b/AvailabilityClick/Services/AvailabilityService.swift @@ -55,6 +55,9 @@ struct AvailabilityService { let bufferMinutes = AppSettings.todayBufferMinutes let eventBufferMinutes = AppSettings.eventBufferMinutes let minimumSlot = TimeInterval(AppSettings.minimumSlotMinutes * 60) + // Loop-invariant: read once, not per iterated day (matches the other + // settings hoisted above). + let granularity = AppSettings.roundingGranularity guard endMinutes > startMinutes else { return [:] } @@ -94,7 +97,6 @@ struct AvailabilityService { ) // Round slot boundaries to configured granularity - let granularity = AppSettings.roundingGranularity let roundedSlots: [TimeSlot] if granularity > 0 { roundedSlots = freeSlots.compactMap { slot in @@ -246,16 +248,23 @@ struct AvailabilityService { return days } - private func nextWeekDays(from today: Date, workingDays: Set) -> [Date] { - // Find next Monday + /// The Monday of the following week — shared by the next-week and fortnight + /// ranges. `(9 - weekday) % 7` maps any weekday to the offset of the coming + /// Monday; an exact 0 means today is Monday, so roll a full week forward. + private func nextMonday(from today: Date) -> Date? { let weekday = calendar.component(.weekday, from: today) let daysUntilNextMonday = (9 - weekday) % 7 let offset = daysUntilNextMonday == 0 ? 7 : daysUntilNextMonday - guard let nextMonday = calendar.date(byAdding: .day, value: offset, to: today) else { return [] } + return calendar.date(byAdding: .day, value: offset, to: today) + } + /// `count` consecutive calendar days from `start` (inclusive), keeping only + /// those in `workingDays`. Shared by the fixed-window ranges; no today-buffer + /// logic (those ranges start in the future). + private func collectDays(from start: Date, count: Int, workingDays: Set) -> [Date] { var days: [Date] = [] - for i in 0..<7 { - guard let day = calendar.date(byAdding: .day, value: i, to: nextMonday) else { continue } + for i in 0..) -> [Date] { - // 14 calendar days starting from next Monday - let weekday = calendar.component(.weekday, from: today) - let daysUntilNextMonday = (9 - weekday) % 7 - let offset = daysUntilNextMonday == 0 ? 7 : daysUntilNextMonday - guard let nextMonday = calendar.date(byAdding: .day, value: offset, to: today) else { return [] } + private func nextWeekDays(from today: Date, workingDays: Set) -> [Date] { + guard let monday = nextMonday(from: today) else { return [] } + return collectDays(from: monday, count: 7, workingDays: workingDays) + } - var days: [Date] = [] - for i in 0..<14 { - guard let day = calendar.date(byAdding: .day, value: i, to: nextMonday) else { continue } - let wd = calendar.component(.weekday, from: day) - if workingDays.contains(wd) { - days.append(day) - } - } - return days + private func fortnightDays(from today: Date, workingDays: Set) -> [Date] { + guard let monday = nextMonday(from: today) else { return [] } + return collectDays(from: monday, count: 14, workingDays: workingDays) } private func next30CalendarDays(from today: Date, workingDays: Set) -> [Date] { guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) else { return [] } - - var days: [Date] = [] - for i in 0..<30 { - guard let day = calendar.date(byAdding: .day, value: i, to: tomorrow) else { continue } - let wd = calendar.component(.weekday, from: day) - if workingDays.contains(wd) { - days.append(day) - } - } - return days + return collectDays(from: tomorrow, count: 30, workingDays: workingDays) } /// Like `next30CalendarDays` but starting TODAY (U4 proposal window): diff --git a/AvailabilityClick/Services/CalendarService.swift b/AvailabilityClick/Services/CalendarService.swift index c547ded..6a307d4 100644 --- a/AvailabilityClick/Services/CalendarService.swift +++ b/AvailabilityClick/Services/CalendarService.swift @@ -19,7 +19,7 @@ private final class UncheckedSendableBox: @unchecked Sendable { @MainActor final class CalendarService { static let shared = CalendarService() - let store = EKEventStore() + private let store = EKEventStore() private init() { AppSettings.registerDefaults() diff --git a/AvailabilityClick/Utilities/PasteboardWriter.swift b/AvailabilityClick/Utilities/PasteboardWriter.swift index 26ee6f3..4e511d0 100644 --- a/AvailabilityClick/Utilities/PasteboardWriter.swift +++ b/AvailabilityClick/Utilities/PasteboardWriter.swift @@ -52,19 +52,7 @@ enum PasteboardWriter { timezone: timezone, asOf: asOf ) - let fullRange = NSRange(location: 0, length: attributed.length) - if let rtf = try? attributed.data( - from: fullRange, - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf] - ) { - pasteboard.setData(rtf, forType: .rtf) - } - if let html = try? attributed.data( - from: fullRange, - documentAttributes: [.documentType: NSAttributedString.DocumentType.html] - ) { - pasteboard.setData(html, forType: .html) - } + writeRichFlavors(attributed, to: pasteboard) } pasteboard.setString(plain, forType: .string) @@ -86,20 +74,29 @@ enum PasteboardWriter { string: text, attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] ) + writeRichFlavors(attributed, to: pasteboard) + + pasteboard.setString(text, forType: .string) + lastWriteChangeCount = pasteboard.changeCount + return true + } + + /// Writes the .rtf and .html flavors derived from `attributed`. Best-effort: + /// a flavor whose serialization fails is skipped, never fatal. Callers own + /// clearContents(), the .string flavor, and the changeCount snapshot. + private static func writeRichFlavors(_ attributed: NSAttributedString, to pasteboard: NSPasteboard) { let fullRange = NSRange(location: 0, length: attributed.length) if let rtf = try? attributed.data( - from: fullRange, documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf] + from: fullRange, + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf] ) { pasteboard.setData(rtf, forType: .rtf) } if let html = try? attributed.data( - from: fullRange, documentAttributes: [.documentType: NSAttributedString.DocumentType.html] + from: fullRange, + documentAttributes: [.documentType: NSAttributedString.DocumentType.html] ) { pasteboard.setData(html, forType: .html) } - - pasteboard.setString(text, forType: .string) - lastWriteChangeCount = pasteboard.changeCount - return true } } diff --git a/AvailabilityClick/Utilities/StatusItemController.swift b/AvailabilityClick/Utilities/StatusItemController.swift index 071496b..7e097c8 100644 --- a/AvailabilityClick/Utilities/StatusItemController.swift +++ b/AvailabilityClick/Utilities/StatusItemController.swift @@ -34,10 +34,7 @@ final class StatusItemController: NSObject { guard let button = statusItem.button else { return } // ~20% larger than the default 16pt by using a point size config - let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium) - let image = NSImage(systemSymbolName: "calendar", accessibilityDescription: "Availability Click")? - .withSymbolConfiguration(config) - image?.isTemplate = true + let image = Self.templateSymbolImage("calendar", accessibilityDescription: "Availability Click") baseImage = image button.image = image @@ -354,12 +351,7 @@ final class StatusItemController: NSObject { /// flash reverts to the attention-aware baseline, not the raw base image, /// so a persistent badge survives an overlapping flash (KTD7). func showOutcome(_ outcome: CopyOutcome) { - let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium) - let image = NSImage(systemSymbolName: outcome.symbolName, accessibilityDescription: nil)? - .withSymbolConfiguration(config) - image?.isTemplate = true - - statusItem?.button?.image = image + statusItem?.button?.image = Self.templateSymbolImage(outcome.symbolName) statusItem?.button?.toolTip = outcome.tooltip // User-triggered outcomes speak at high priority (R13). Posted once @@ -388,8 +380,15 @@ final class StatusItemController: NSObject { /// returns nil and the caller falls back to `baseImage`. static func attentionSymbolImage(for state: AttentionState) -> NSImage? { guard let symbol = state.symbolName else { return nil } + return templateSymbolImage(symbol) + } + + /// A 15pt medium template SF Symbol image — the single icon style used + /// across the menu bar (base icon, outcome flash, attention badge). Static + /// so both the instance paths and `attentionSymbolImage` share it. + private static func templateSymbolImage(_ name: String, accessibilityDescription: String? = nil) -> NSImage? { let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium) - let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? + let image = NSImage(systemSymbolName: name, accessibilityDescription: accessibilityDescription)? .withSymbolConfiguration(config) image?.isTemplate = true return image diff --git a/AvailabilityClick/Views/PreviewPopoverView.swift b/AvailabilityClick/Views/PreviewPopoverView.swift index 38f0571..6398cba 100644 --- a/AvailabilityClick/Views/PreviewPopoverView.swift +++ b/AvailabilityClick/Views/PreviewPopoverView.swift @@ -159,18 +159,32 @@ struct PreviewPopoverView: View { return "\(name) (\(AvailabilityFormatter.timezoneString(for: tz)))" } - private var filteredTimezones: [TimeZone] { - let all = TimeZone.knownTimeZoneIdentifiers + /// Every known zone, sorted by GMT offset, each with its three search fields + /// pre-lowercased — built once, not rebuilt on each `body` evaluation (a + /// format-picker tap or a row selection re-renders the popover). Precomputing + /// the fields keeps a keystroke from calling the locale-aware `localizedName` + /// on ~450 zones. The GMT offset and the DST-dependent abbreviation are + /// sampled at first access, so a DST shift mid-session is a cosmetic ordering + /// and abbreviation-search difference. + private static let searchableTimezones: [(zone: TimeZone, id: String, abbr: String, name: String)] = + TimeZone.knownTimeZoneIdentifiers .compactMap { TimeZone(identifier: $0) } .sorted { $0.secondsFromGMT() < $1.secondsFromGMT() } + .map { tz in + ( + tz, + tz.identifier.lowercased(), + (tz.abbreviation() ?? "").lowercased(), + (tz.localizedName(for: .standard, locale: .current) ?? "").lowercased() + ) + } - if searchText.isEmpty { return Array(all.prefix(20)) } + private var filteredTimezones: [TimeZone] { + if searchText.isEmpty { return Array(Self.searchableTimezones.prefix(20).map(\.zone)) } let query = searchText.lowercased() - return all.filter { tz in - tz.identifier.lowercased().contains(query) - || (tz.abbreviation() ?? "").lowercased().contains(query) - || (tz.localizedName(for: .standard, locale: .current) ?? "").lowercased().contains(query) - } + return Self.searchableTimezones + .filter { $0.id.contains(query) || $0.abbr.contains(query) || $0.name.contains(query) } + .map(\.zone) } } diff --git a/AvailabilityClick/Views/SettingsView.swift b/AvailabilityClick/Views/SettingsView.swift index 8aaf41a..2e8fe2c 100644 --- a/AvailabilityClick/Views/SettingsView.swift +++ b/AvailabilityClick/Views/SettingsView.swift @@ -1,6 +1,5 @@ import SwiftUI import AppKit -import EventKit import ServiceManagement struct SettingsView: View { diff --git a/AvailabilityClickTests/AvailabilityClickTests.swift b/AvailabilityClickTests/AvailabilityClickTests.swift index 9d17bfc..e6f4b32 100644 --- a/AvailabilityClickTests/AvailabilityClickTests.swift +++ b/AvailabilityClickTests/AvailabilityClickTests.swift @@ -2687,6 +2687,29 @@ struct StaleWatchGuardTests { #expect(!AppDelegate.copiedSlotsBecameStale(watched: s, fresh: s)) } + // MARK: - Slot-settings signature (R5 false-badge guard) + + @Test func settingsSignature_stableWhenSettingsUnchanged() async { + var a: SlotSettingsSignature? + var b: SlotSettingsSignature? + await withPinnedSettings(stockWorkingSettings) { a = .current } + await withPinnedSettings(stockWorkingSettings) { b = .current } + #expect(a == b) + } + + @Test func settingsSignature_changesWhenSlotShapingSettingChanges() async { + // Changing a slot-shaping setting (here Slot Rounding) after a copy must + // change the signature, so the stale-copy recheck skips rather than + // false-badging unbooked slots as "no longer free" (adversarial P3). + var base: SlotSettingsSignature? + var changed: SlotSettingsSignature? + var altered = stockWorkingSettings + altered[AppSettings.roundingGranularityKey] = 15 // stock is 30 + await withPinnedSettings(stockWorkingSettings) { base = .current } + await withPinnedSettings(altered) { changed = .current } + #expect(base != changed) + } + // MARK: - Trailing debounce coalescing (KTD11) @Test func debouncer_onlyNewestGenerationIsCurrent() { diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9facf..d599bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to Availability Click are documented here. +## v1.2.0 + +The first update since launch, gathering everything built after v1.0.0. + +New: + +- A right-click "Copy 3 Suggested Times" action writes a short numbered proposal of well-spread times instead of the full grid. +- The global hotkey now fires on a fresh install with no extra permissions. Tap it to copy, hold it to open the preview. +- Copied text can carry an optional "as of" timestamp, and the timezone label now reads as a plain zone name with a single GMT offset ("Berlin Time, GMT+2"). +- The menu bar icon flags when a time you copied is no longer free, and confirms before overwriting your own unpasted output once availability has changed. +- An optional per-event buffer pads meetings so offered slots never start or end flush against one. +- Shortcuts and Spotlight can now read your availability through two App Intents, one returning formatted text and one returning structured start and end slots, under a documented automation contract that later versions only add to. +- Every copy outcome is announced to VoiceOver, and copied output now carries rich text with bold day labels in Mail, Notion, and Google Docs. + +Improved: + +- Output follows your locale, including 24-hour time where the locale uses it. +- A failed click now says why: no calendar access, no calendars available, or no free slots in the range. +- A first-run coach shows the click, right-click, and Option-click gestures once, and the menu carries the running version and a Check for Updates link. +- Deselecting your last calendar is no longer possible, and a fully stale calendar selection stops rather than silently widening to every calendar. + +Still App-Sandboxed with calendar access as the only entitlement. No network, no analytics, no tracking. Signed with a Developer ID and notarized by Apple. + ## v1.0.0 First public release. diff --git a/README.md b/README.md index 9ee50f9..331af81 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A macOS menu bar app that reads your calendars and copies your availability to paste into an email or chat. One click. -[Download the app](https://github.com/Reebz/availability-click/releases/download/v1.0.0/availability-click_v1.0.0.dmg), or install with Homebrew: `brew install --cask Reebz/availability-click/availability-click`. +[Download the app](https://github.com/Reebz/availability-click/releases/download/v1.2.0/availability-click_v1.2.0.dmg), or install with Homebrew: `brew install --cask Reebz/availability-click/availability-click`. ``` Mon Mar 30: 9-10:30am, 2-4pm @@ -31,7 +31,7 @@ brew install --cask Reebz/availability-click/availability-click **Or download the DMG** -[Download the latest release](https://github.com/Reebz/availability-click/releases/download/v1.0.0/availability-click_v1.0.0.dmg), open it, and drag Availability Click to your Applications folder. Grant calendar access on first launch. +[Download the latest release](https://github.com/Reebz/availability-click/releases/download/v1.2.0/availability-click_v1.2.0.dmg), open it, and drag Availability Click to your Applications folder. Grant calendar access on first launch. Requires macOS 14 (Sonoma) or later. The app is signed with a Developer ID and notarized by Apple, so it opens without a security warning. @@ -107,7 +107,7 @@ One scoping note: the Shortcuts action hands your availability text to your own - Zero third-party dependencies - XcodeGen for project generation - macOS 14.0+ (Sonoma) -- 163 tests across nineteen suites +- 247 tests across twenty-eight suites - App Sandbox, Hardened Runtime, Developer ID signed and notarized ## Support diff --git a/project.yml b/project.yml index a988793..b2c742b 100644 --- a/project.yml +++ b/project.yml @@ -13,8 +13,8 @@ settings: ENABLE_HARDENED_RUNTIME: YES CODE_SIGN_IDENTITY: "-" PRODUCT_BUNDLE_IDENTIFIER: com.availabilityclick.AvailabilityClick - MARKETING_VERSION: "1.0.0" - CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "1.2.0" + CURRENT_PROJECT_VERSION: "2" targets: AvailabilityClick: diff --git a/site/index.html b/site/index.html index 863de0a..d8249dc 100644 --- a/site/index.html +++ b/site/index.html @@ -508,7 +508,7 @@

Availability Click

Your calendar already knows when you're free. This puts it on the clipboard in one click so you can paste it into an email and move on.

- + Download for macOS @@ -609,7 +609,7 @@

Get started

Download for macOS

Requires macOS 14 (Sonoma) or later. Open the app, grant calendar access, and you're done.

- + Download from GitHub