From 5903606f1f5f48afc71ad289c5110926e3f54f6e Mon Sep 17 00:00:00 2001 From: Mitch Date: Sun, 12 Jul 2026 10:39:08 +1000 Subject: [PATCH 1/2] refactor: dedupe shared helpers and fix pre-release review findings A behavior-preserving simplify pass plus fixes from a whole-codebase review, ahead of the v1.2 release. Simplify: pull duplicated logic into shared helpers so parallel paths can't drift (pasteboard flavors, the two Shortcuts intents' pipeline, the formatter's trailing lines, the status-item symbol builder, the copy auth/debounce/calendars guards, and the week/fortnight/30-day range builders), and hoist a per-day settings read and the ~450-zone timezone list out of hot paths. Fix: the intents now share one `now` across the window and slot math, so a clock crossing midnight mid-fetch can't report a phantom free day. The timezone search caches its fields instead of re-localizing every zone per keystroke. Each copy snapshots its slot-shaping settings, so changing rounding afterward no longer false-badges copied slots as no longer free. 247 tests pass. Co-Authored-By: Dwight Kurt Schrute III (Claude Opus 4.8 (1M context)) --- AvailabilityClick/AppDelegate.swift | 125 ++++++++++++------ .../Intents/GetAvailabilityIntent.swift | 60 ++++++--- .../Intents/GetAvailabilitySlotsIntent.swift | 19 +-- .../Services/AvailabilityFormatter.swift | 32 +++-- .../Services/AvailabilityService.swift | 54 ++++---- .../Services/CalendarService.swift | 2 +- .../Utilities/PasteboardWriter.swift | 35 +++-- .../Utilities/StatusItemController.swift | 21 ++- .../Views/PreviewPopoverView.swift | 30 +++-- AvailabilityClick/Views/SettingsView.swift | 1 - .../AvailabilityClickTests.swift | 23 ++++ 11 files changed, 239 insertions(+), 163 deletions(-) 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() { From 97efc87682897366140d32d2a466cc96a06a775f Mon Sep 17 00:00:00 2001 From: Mitch Date: Sun, 12 Jul 2026 10:39:14 +1000 Subject: [PATCH 2/2] chore(release): cut v1.2.0 Bump the marketing version to 1.2.0 (build 2), the first release since launch. Add the v1.2.0 changelog covering everything built after v1.0.0, and point the README and site download links at the new DMG. The README test count catches up to 247. Co-Authored-By: Dwight Kurt Schrute III (Claude Opus 4.8 (1M context)) --- AvailabilityClick.xcodeproj/project.pbxproj | 2 +- CHANGELOG.md | 23 +++++++++++++++++++++ README.md | 6 +++--- project.yml | 4 ++-- site/index.html | 4 ++-- 5 files changed, 31 insertions(+), 8 deletions(-) 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/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