Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AvailabilityClick.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
path = Intents;
sourceTree = "<group>";
};
"TEMP_32DE843E-175A-4E3B-BD4C-D545AA8D210F" /* .claude */ = {
"TEMP_6B525DBB-9255-4ED2-9587-337BE20C32C0" /* .claude */ = {
isa = PBXGroup;
children = (
);
Expand Down
125 changes: 85 additions & 40 deletions AvailabilityClick/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<TimeSlot>
let rangeType: DateRangeType
let now: Date
let settings: SlotSettingsSignature
}

@MainActor
Expand Down Expand Up @@ -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<TimeSlot>, 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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 38 additions & 22 deletions AvailabilityClick/Intents/GetAvailabilityIntent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -78,28 +115,7 @@ struct GetAvailabilityIntent: AppIntent {

@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<String> {
// 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).
Expand Down
19 changes: 1 addition & 18 deletions AvailabilityClick/Intents/GetAvailabilitySlotsIntent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 20 additions & 12 deletions AvailabilityClick/Services/AvailabilityFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
Expand Down
Loading
Loading