From 658e83bc79e2cdc71f4433523d36ff33a6eac298 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 18:59:39 +0000 Subject: [PATCH 1/9] feat(macos): day-prefix lowercase event jump chips parity with web Co-authored-by: Tyler Dane --- .../Compass/NativeGridKeyboardRouter.swift | 8 +- .../NativeCalendarRootModel+EventJump.swift | 91 +++++++++- .../CompassData/NativeCalendarRootModel.swift | 80 +++++++-- .../Stores/EventJumpSelectionStore.swift | 6 + ...ativeCalendarRootModelEventJumpTests.swift | 27 +++ .../Keyboard/DayJumpKeyAssignment.swift | 169 ++++++++++++++++++ .../DayJumpKeyAssignmentTests.swift | 47 +++++ .../Chips/EventJumpChipsOverlay.swift | 2 + .../EventJumpDayPrefixUITests.swift | 28 +++ 9 files changed, 435 insertions(+), 23 deletions(-) create mode 100644 apps/calendar-macos/CompassKit/Sources/CompassKit/Keyboard/DayJumpKeyAssignment.swift create mode 100644 apps/calendar-macos/CompassKitTests/DayJumpKeyAssignmentTests.swift create mode 100644 apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift diff --git a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index 396d87f496..41c17593f2 100644 --- a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift +++ b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift @@ -596,8 +596,14 @@ final class NativeGridKeyboardRouter { return true } + if keyEvent.modifiers.isEmpty, case .character(let char) = keyEvent.key, char.isLetter { + if model.eventJumpSelection.modeActive, model.handleEventJumpKeystroke(char) { + return true + } + } + if keyEvent.modifiers.isEmpty, case .character(let digit) = keyEvent.key, digit.isNumber { - if model.eventJumpSelection.modeActive, model.focusEventJumpIfAvailable(digit: digit) { + if model.eventJumpSelection.modeActive, model.handleEventJumpKeystroke(digit) { return true } model.handleQuickTimeDigit(digit) diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift index 8f8bea79a1..6fea81ecf1 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift @@ -25,15 +25,97 @@ extension NativeCalendarRootModel { return "Event jump ยท Esc" } + public func activateEventJumpMode() { + guard !eventJumpSelection.modeActive else { return } + eventJumpSelection.setModeActive(true) + recordShortcut(.focusShiftHold) + refreshEventJumpPresentation() + } + public func toggleEventJumpMode() { if eventJumpSelection.modeActive { clearEventJumpSelection() draftStore.setQuickTimeDigits("") return } - eventJumpSelection.setModeActive(true) - recordShortcut(.focusShiftHold) - refreshEventJumpPresentation() + activateEventJumpMode() + } + + @discardableResult + public func handleEventJumpKeystroke(_ key: Character) -> Bool { + guard eventJumpSelection.modeActive else { return false } + let dayKeyByPrefix = dayKeyByPrefixForJump() + guard + let match = DayJumpKeyAssignment.matchKeystroke( + assignments: eventJumpAssignments, + dayKeyByPrefix: dayKeyByPrefix, + key: key, + buffer: eventJumpSelection.dayJumpBuffer + ) + else { return false } + applyEventJumpMatch(match) + return true + } + + private func dayKeyByPrefixForJump() -> [String: String] { + let calendar = EffectiveTimeZone.calendar + var map: [String: String] = [:] + for dayKey in visibleDateKeys() { + guard let date = CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) else { continue } + let weekday = calendar.component(.weekday, from: date) - 1 + let prefix = DayJumpPrefixes.prefix(forWeekday: weekday) + map[prefix] = dayKey + } + return map + } + + private func applyEventJumpMatch(_ match: DayJumpKeyAssignment.MatchResult) { + eventJumpAmbiguousTask?.cancel() + eventJumpAmbiguousTask = nil + + switch match { + case let .selectDay(dayPrefix, dayKey, firstEventId, buffer): + let name = DayJumpPrefixes.displayName(forPrefix: dayPrefix) + eventJumpSelection.selectDay( + key: dayKey, + announcement: "\(name) selected", + buffer: buffer + ) + recordShortcut(.focusWeekDay) + if let firstEventId { + focusEvent(eventId: firstEventId) + } + refreshEventJumpPresentation() + + case let .prefix(buffer, dayKeys, pendingExactEventId): + if buffer == "s", dayKeys.isEmpty { + eventJumpSelection.setWeekendDisambiguation(announcement: "Weekend") + } else { + eventJumpSelection.setPrefixBuffer(buffer, dayKeys: dayKeys, announcement: nil) + } + if let pendingExactEventId { + scheduleAmbiguousEventJumpCommit(eventId: pendingExactEventId, buffer: buffer) + } + refreshEventJumpPresentation() + + case let .focus(eventId, _, buffer): + eventJumpSelection.setPrefixBuffer(buffer, dayKeys: eventJumpSelection.activeDayKeys, announcement: nil) + focusEvent(eventId: eventId) + refreshEventJumpPresentation() + } + } + + private func scheduleAmbiguousEventJumpCommit(eventId: String, buffer: String) { + eventJumpAmbiguousTask?.cancel() + eventJumpAmbiguousTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self, self.eventJumpSelection.dayJumpBuffer == buffer else { return } + self.focusEvent(eventId: eventId) + self.refreshEventJumpPresentation() + } + } } func refreshEventJumpPresentation() { @@ -100,7 +182,10 @@ extension NativeCalendarRootModel { } public func clearEventJumpSelection() { + eventJumpAmbiguousTask?.cancel() + eventJumpAmbiguousTask = nil eventJumpSelection.clear() + eventJumpAssignments = [] eventJumpHintLabels = [] quickTimeSlotHintLabels = [] applyFocusPresentation() diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index 0708ee4177..5b6efe409a 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -120,6 +120,8 @@ public final class NativeCalendarRootModel { private var refreshTask: Task? private var focusLayoutCards: [FocusLayoutCard] = [] var eventJumpHintLabels: [EventJumpChipHint] = [] + var eventJumpAssignments: [DayJumpKeyAssignment.Assignment] = [] + var eventJumpAmbiguousTask: Task? var quickTimeSlotHintLabels: [QuickTimeSlotHint] = [] public var focusedDayColumnCalendarId: String? public var pendingFocusDayColumnCalendarId: String? @@ -674,7 +676,11 @@ public final class NativeCalendarRootModel { case .focusNotice: focusNextNotice() case .focusShiftHold: - toggleEventJumpMode() + if eventJumpSelection.modeActive { + _ = handleEventJumpKeystroke("f") + } else { + activateEventJumpMode() + } case .otherSubscribe: openUpgradeConfirmation() case .navGoToDate: @@ -792,17 +798,7 @@ public final class NativeCalendarRootModel { } public func focusEventJump(digit: Character) { - guard let hint = eventJumpHintLabels.first(where: { $0.label == String(digit) }) else { return } - focusEvent(eventId: hint.eventId) - } - - @discardableResult - public func focusEventJumpIfAvailable(digit: Character) -> Bool { - guard let hint = eventJumpHintLabels.first(where: { $0.label == String(digit) }) else { - return false - } - focusEvent(eventId: hint.eventId) - return true + _ = handleEventJumpKeystroke(digit) } public func consumePendingScroll() -> TimeGridScrollRequest? { @@ -1238,17 +1234,63 @@ public final class NativeCalendarRootModel { } func buildEventJumpHints() -> [EventJumpChipHint] { - let digits = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] - let timed = focusLayoutCards.filter { !$0.isAllDay }.sorted { $0.frame.top < $1.frame.top } - return timed.prefix(digits.count).enumerated().map { index, card in - EventJumpChipHint( - eventId: card.eventId, - label: digits[index], - frame: card.frame + let targets = buildEventJumpTargets() + eventJumpAssignments = DayJumpKeyAssignment.assign(targets) + let filtered = DayJumpKeyAssignment.filter( + eventJumpAssignments, + prefix: eventJumpSelection.dayJumpBuffer + ) + let frameByEventId = Dictionary( + uniqueKeysWithValues: focusLayoutCards.map { ($0.eventId, $0.frame) } + ) + return filtered.compactMap { assignment in + guard let frame = frameByEventId[assignment.eventId] else { return nil } + return EventJumpChipHint( + eventId: assignment.eventId, + label: assignment.hint, + frame: frame ) } } + private func buildEventJumpTargets() -> [DayJumpKeyAssignment.Target] { + let calendar = EffectiveTimeZone.calendar + let eventsById = Dictionary(uniqueKeysWithValues: loadedEvents.map { ($0.id.rawValue, $0) }) + var targets: [DayJumpKeyAssignment.Target] = [] + for card in focusLayoutCards { + guard let event = eventsById[card.eventId] else { continue } + switch event.schedule { + case .timed(let timed): + let start = CompassDateParsing.parseInEffectiveTimeZone(timed.start.rawValue) ?? referenceNow + let dayKey = CompassDateParsing.formatCalendarDay(start) + let weekday = calendar.component(.weekday, from: start) - 1 + targets.append( + DayJumpKeyAssignment.Target( + eventId: card.eventId, + startMs: Int64(start.timeIntervalSince1970 * 1000), + eventType: .timed, + dayKey: dayKey, + weekday: weekday + ) + ) + case .allDay(let allDay): + let start = CompassDateParsing.calendarDateInEffectiveTimeZone(allDay.start) ?? referenceNow + let dayKey = CompassDateParsing.formatCalendarDay(start) + let weekday = calendar.component(.weekday, from: start) - 1 + targets.append( + DayJumpKeyAssignment.Target( + eventId: card.eventId, + startMs: Int64(start.timeIntervalSince1970 * 1000), + eventType: .allDay, + dayKey: dayKey, + weekday: weekday + ) + ) + } + } + return targets + } + public func visibleCalendars() -> [CompassCalendar] { calendars.filter { $0.isActive && calendarVisibilityStore.isVisible($0.id) } } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift index c50f39da87..d677cbd967 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift @@ -26,6 +26,12 @@ public final class EventJumpSelectionStore { dayJumpBuffer = "s" } + public func setPrefixBuffer(_ buffer: String, dayKeys: [String], announcement: String?) { + dayJumpBuffer = buffer + activeDayKeys = dayKeys + self.announcement = announcement + } + public func setModeActive(_ active: Bool) { modeActive = active if !active { diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift index 05ff9db1fe..6cf4f7b2df 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift @@ -19,6 +19,33 @@ final class NativeCalendarRootModelEventJumpTests: XCTestCase { XCTAssertTrue(model.timeGridState.quickTimeSlotHints.isEmpty) } + func testEventJumpHintsUseLowercaseDayPrefixLabels() async throws { + let fixture = try DemoSeedFixture.load() + let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + let model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture) + await model.start() + model.viewStore.view = .week + model.activateEventJumpMode() + let labels = model.timeGridState.eventJumpHints.map(\.label) + XCTAssertFalse(labels.isEmpty) + for label in labels { + XCTAssertEqual(label, label.lowercased()) + XCTAssertTrue(label.range(of: #"^(su|m|t|w|r|f|sa)[0-9]+$"#, options: .regularExpression) != nil) + } + } + + func testBareFWhileJumpActiveSelectsFridayNotToggleOff() async throws { + let fixture = try DemoSeedFixture.load() + let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + let model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture) + await model.start() + model.viewStore.view = .week + model.activateEventJumpMode() + XCTAssertTrue(model.handleEventJumpKeystroke("f")) + XCTAssertTrue(model.eventJumpSelection.modeActive) + XCTAssertEqual(model.eventJumpSelection.dayJumpBuffer, "f") + } + func testShiftDayJumpSelectsMondayColumn() async throws { let fixture = try DemoSeedFixture.load() let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Keyboard/DayJumpKeyAssignment.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Keyboard/DayJumpKeyAssignment.swift new file mode 100644 index 0000000000..9178d7710d --- /dev/null +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Keyboard/DayJumpKeyAssignment.swift @@ -0,0 +1,169 @@ +import Foundation + +/// Mirrors web `assign-shift-hint-keys.ts`: day-prefix event jump labels. +public enum DayJumpKeyAssignment { + public struct Target: Sendable, Equatable { + public var eventId: String + public var startMs: Int64 + public var eventType: EventKind + public var dayKey: String + public var weekday: Int + + public enum EventKind: Sendable, Equatable { + case allDay + case timed + } + + public init( + eventId: String, + startMs: Int64, + eventType: EventKind, + dayKey: String, + weekday: Int + ) { + self.eventId = eventId + self.startMs = startMs + self.eventType = eventType + self.dayKey = dayKey + self.weekday = weekday + } + } + + public struct Assignment: Sendable, Equatable { + public var eventId: String + public var hint: String + public var dayKey: String + public var dayPrefix: String + public var index: Int + } + + public enum MatchResult: Sendable, Equatable { + case selectDay(dayPrefix: String, dayKey: String, firstEventId: String?, buffer: String) + case prefix(buffer: String, dayKeys: [String], pendingExactEventId: String?) + case focus(eventId: String, dayKey: String, buffer: String) + } + + private static let uniqueDayLetters: Set = ["m", "t", "w", "r", "f"] + + public static func assign(_ targets: [Target]) -> [Assignment] { + guard !targets.isEmpty else { return [] } + var byDay: [String: [Target]] = [:] + for target in targets { + byDay[target.dayKey, default: []].append(target) + } + var assignments: [Assignment] = [] + for dayKey in byDay.keys.sorted() { + guard var dayTargets = byDay[dayKey] else { continue } + dayTargets.sort { a, b in + if a.dayKey != b.dayKey { return a.dayKey < b.dayKey } + if a.startMs != b.startMs { return a.startMs < b.startMs } + if a.eventType != b.eventType { + return a.eventType == .allDay + } + return a.eventId < b.eventId + } + let weekday = dayTargets[0]?.weekday ?? 0 + let dayPrefix = DayJumpPrefixes.prefix(forWeekday: weekday) + for (index, target) in dayTargets.enumerated() { + let n = index + 1 + assignments.append( + Assignment( + eventId: target.eventId, + hint: "\(dayPrefix)\(n)", + dayKey: dayKey, + dayPrefix: dayPrefix, + index: n + ) + ) + } + } + return assignments + } + + public static func filter(_ assignments: [Assignment], prefix: String) -> [Assignment] { + guard !prefix.isEmpty else { return assignments } + return assignments.filter { $0.hint.hasPrefix(prefix) } + } + + public static func matchKeystroke( + assignments: [Assignment], + dayKeyByPrefix: [String: String], + key: Character, + buffer: String + ) -> MatchResult? { + let lower = String(key).lowercased() + guard lower.count == 1, let char = lower.first else { return nil } + + if char.isNumber { + if buffer.isEmpty { return nil } + if buffer == "s" { return nil } + return matchDigitBuffer(assignments: assignments, next: buffer + String(char)) + } + + guard char.isLetter else { return nil } + + if buffer == "s", char == "u" || char == "a" { + return selectDay( + assignments: assignments, + dayKeyByPrefix: dayKeyByPrefix, + dayPrefix: char == "u" ? "su" : "sa" + ) + } + + if char == "s" { + var dayKeys = Set(filter(assignments, prefix: "s").map(\.dayKey)) + for prefix in ["su", "sa"] { + if let key = dayKeyByPrefix[prefix] { + dayKeys.insert(key) + } + } + if dayKeys.isEmpty { return nil } + return .prefix(buffer: "s", dayKeys: dayKeys.sorted(), pendingExactEventId: nil) + } + + if uniqueDayLetters.contains(char) { + return selectDay( + assignments: assignments, + dayKeyByPrefix: dayKeyByPrefix, + dayPrefix: String(char) + ) + } + + return nil + } + + private static func selectDay( + assignments: [Assignment], + dayKeyByPrefix: [String: String], + dayPrefix: String + ) -> MatchResult? { + let forDay = assignments.filter { $0.dayPrefix == dayPrefix } + let first = forDay.min(by: { $0.index < $1.index }) + guard let dayKey = first?.dayKey ?? dayKeyByPrefix[dayPrefix] else { return nil } + return .selectDay( + dayPrefix: dayPrefix, + dayKey: dayKey, + firstEventId: first?.eventId, + buffer: dayPrefix + ) + } + + private static func matchDigitBuffer(assignments: [Assignment], next: String) -> MatchResult? { + let narrowed = filter(assignments, prefix: next) + if narrowed.isEmpty { return nil } + + let exact = narrowed.first(where: { $0.hint == next }) + let hasLonger = narrowed.contains(where: { $0.hint.count > next.count }) + + if let exact, !hasLonger { + return .focus(eventId: exact.eventId, dayKey: exact.dayKey, buffer: next) + } + + let dayKeys = Array(Set(narrowed.map(\.dayKey))).sorted() + return .prefix( + buffer: next, + dayKeys: dayKeys, + pendingExactEventId: exact?.eventId + ) + } +} diff --git a/apps/calendar-macos/CompassKitTests/DayJumpKeyAssignmentTests.swift b/apps/calendar-macos/CompassKitTests/DayJumpKeyAssignmentTests.swift new file mode 100644 index 0000000000..2b128fb5b2 --- /dev/null +++ b/apps/calendar-macos/CompassKitTests/DayJumpKeyAssignmentTests.swift @@ -0,0 +1,47 @@ +import CompassKit +import XCTest + +final class DayJumpKeyAssignmentTests: XCTestCase { + func testAssignsWeekdayPrefixesAndPerDayIndices() { + let assignments = DayJumpKeyAssignment.assign([ + .init( + eventId: "wed-2", startMs: 200, eventType: .timed, dayKey: "2026-08-05", weekday: 3), + .init( + eventId: "wed-1", startMs: 100, eventType: .timed, dayKey: "2026-08-05", weekday: 3), + .init( + eventId: "mon-1", startMs: 50, eventType: .timed, dayKey: "2026-08-03", weekday: 1), + ]) + XCTAssertEqual(assignments.map(\.hint), ["m1", "w1", "w2"]) + } + + func testMatchFocusesWithDayPrefixAndIndex() { + let assignments = DayJumpKeyAssignment.assign([ + .init( + eventId: "fri-4", startMs: 400, eventType: .timed, dayKey: "2026-08-07", weekday: 5), + .init( + eventId: "fri-1", startMs: 100, eventType: .timed, dayKey: "2026-08-07", weekday: 5), + ]) + let map = ["f": "2026-08-07"] + let match = DayJumpKeyAssignment.matchKeystroke( + assignments: assignments, dayKeyByPrefix: map, key: "4", buffer: "f") + XCTAssertEqual(match, .focus(eventId: "fri-4", dayKey: "2026-08-07", buffer: "f4")) + } + + func testWeekendDisambiguationFromS() { + let assignments = DayJumpKeyAssignment.assign([ + .init( + eventId: "sun", startMs: 1, eventType: .timed, dayKey: "2026-08-02", weekday: 0), + ]) + let map = ["su": "2026-08-02", "sa": "2026-08-08"] + let first = DayJumpKeyAssignment.matchKeystroke( + assignments: assignments, dayKeyByPrefix: map, key: "s", buffer: "") + XCTAssertEqual( + first, + .prefix(buffer: "s", dayKeys: ["2026-08-02"], pendingExactEventId: nil)) + let second = DayJumpKeyAssignment.matchKeystroke( + assignments: assignments, dayKeyByPrefix: map, key: "u", buffer: "s") + XCTAssertEqual( + second, + .selectDay(dayPrefix: "su", dayKey: "2026-08-02", firstEventId: "sun", buffer: "su")) + } +} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Chips/EventJumpChipsOverlay.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Chips/EventJumpChipsOverlay.swift index daccd1b3e6..4d95485524 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Chips/EventJumpChipsOverlay.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Chips/EventJumpChipsOverlay.swift @@ -11,6 +11,8 @@ struct EventJumpChipsOverlay: View { GeometryReader { geometry in ForEach(hints) { hint in ShortcutHintChip(label: hint.label) + .accessibilityIdentifier("compass-event-jump-chip") + .accessibilityLabel(hint.label) .position( x: hint.frame.left + hint.frame.width - 14, y: gridYOffset + hint.frame.top + 12 diff --git a/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift b/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift new file mode 100644 index 0000000000..6cb499c68c --- /dev/null +++ b/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift @@ -0,0 +1,28 @@ +import XCTest + +final class EventJumpDayPrefixUITests: XCTestCase { + @MainActor + func testLowercaseDayPrefixChipFocusesEvent() { + let app = XCUIApplication.compassUITest() + app.launchArguments += ["-COMPASS_FIXTURE", "demo"] + app.launch() + let window = app.windows["Compass"] + XCTAssertTrue(window.waitForExistence(timeout: 8)) + window.click() + window.typeText("f") + let chips = window.otherElements["compass-event-jump-chips"] + XCTAssertTrue(chips.waitForExistence(timeout: 8)) + let chipLabels = window.staticTexts.matching(identifier: "compass-event-jump-chip") + XCTAssertGreaterThan(chipLabels.count, 0) + let label = chipLabels.element(boundBy: 0).label + XCTAssertFalse(label.isEmpty) + XCTAssertEqual(label, label.lowercased()) + window.typeText(label) + let probe = window.descendants(matching: .any)["compass-grid-event-focused"] + XCTAssertTrue(probe.waitForExistence(timeout: 8)) + let evidence = XCTAttachment(screenshot: window.screenshot()) + evidence.name = "Day-prefix event jump chips" + evidence.lifetime = .keepAlways + add(evidence) + } +} From 0850cf6ef1b614c39bafd9176900f9b41bc51853 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 20:10:52 +0000 Subject: [PATCH 2/9] fix(macos): remove invalid optional chaining on day jump weekday Co-authored-by: Tyler Dane --- .../Sources/CompassKit/Keyboard/DayJumpKeyAssignment.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Keyboard/DayJumpKeyAssignment.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Keyboard/DayJumpKeyAssignment.swift index 9178d7710d..f37f85d5c1 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Keyboard/DayJumpKeyAssignment.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Keyboard/DayJumpKeyAssignment.swift @@ -62,7 +62,7 @@ public enum DayJumpKeyAssignment { } return a.eventId < b.eventId } - let weekday = dayTargets[0]?.weekday ?? 0 + let weekday = dayTargets[0].weekday let dayPrefix = DayJumpPrefixes.prefix(forWeekday: weekday) for (index, target) in dayTargets.enumerated() { let n = index + 1 From 908a0a26b37c067207544d559b1a311509abe621 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 20:51:02 +0000 Subject: [PATCH 3/9] test(macos): align day jump keystroke tests with web parity Co-authored-by: Tyler Dane --- .../DayJumpKeyAssignmentTests.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/calendar-macos/CompassKitTests/DayJumpKeyAssignmentTests.swift b/apps/calendar-macos/CompassKitTests/DayJumpKeyAssignmentTests.swift index 2b128fb5b2..20ad753943 100644 --- a/apps/calendar-macos/CompassKitTests/DayJumpKeyAssignmentTests.swift +++ b/apps/calendar-macos/CompassKitTests/DayJumpKeyAssignmentTests.swift @@ -15,12 +15,15 @@ final class DayJumpKeyAssignmentTests: XCTestCase { } func testMatchFocusesWithDayPrefixAndIndex() { - let assignments = DayJumpKeyAssignment.assign([ - .init( - eventId: "fri-4", startMs: 400, eventType: .timed, dayKey: "2026-08-07", weekday: 5), - .init( - eventId: "fri-1", startMs: 100, eventType: .timed, dayKey: "2026-08-07", weekday: 5), - ]) + let assignments = DayJumpKeyAssignment.assign( + (1 ... 4).map { index in + DayJumpKeyAssignment.Target( + eventId: "fri-\(index)", + startMs: Int64(index * 100), + eventType: .timed, + dayKey: "2026-08-07", + weekday: 5) + }) let map = ["f": "2026-08-07"] let match = DayJumpKeyAssignment.matchKeystroke( assignments: assignments, dayKeyByPrefix: map, key: "4", buffer: "f") @@ -37,7 +40,7 @@ final class DayJumpKeyAssignmentTests: XCTestCase { assignments: assignments, dayKeyByPrefix: map, key: "s", buffer: "") XCTAssertEqual( first, - .prefix(buffer: "s", dayKeys: ["2026-08-02"], pendingExactEventId: nil)) + .prefix(buffer: "s", dayKeys: ["2026-08-02", "2026-08-08"], pendingExactEventId: nil)) let second = DayJumpKeyAssignment.matchKeystroke( assignments: assignments, dayKeyByPrefix: map, key: "u", buffer: "s") XCTAssertEqual( From e5b281a59fae81039f9b1693e4556e7a635104d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 22:18:29 +0000 Subject: [PATCH 4/9] fix(macos): wire focus-shift-hold shortcut for event jump mode Co-authored-by: Tyler Dane --- apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index 41c17593f2..197f9db4c2 100644 --- a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift +++ b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift @@ -68,6 +68,7 @@ final class NativeGridKeyboardRouter { .navGoToDate, .otherSidebar, .focusSidebar, + .focusShiftHold, .focusNotice, .otherSubscribe, ] From a12a752acc5f9ab239289196743875c6bef8b5b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 23:18:49 +0000 Subject: [PATCH 5/9] fix(macos): handle bare f for event jump like web Co-authored-by: Tyler Dane --- .../Compass/NativeGridKeyboardRouter.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index 197f9db4c2..02b15d223b 100644 --- a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift +++ b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift @@ -367,6 +367,21 @@ final class NativeGridKeyboardRouter { return true } + if keyEvent.modifiers.isEmpty, + case .character(let char) = keyEvent.key, + char.lowercased() == "f", + model.viewStore.view != .life + { + if model.eventJumpSelection.modeActive { + if model.handleEventJumpKeystroke("f") { + return true + } + } else { + model.activateEventJumpMode() + return true + } + } + if handleDraftKeys(keyEvent) { return true } From c6c0c0c9a6597184fa4c216d661b6c6c1a0aa61b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 23:54:59 +0000 Subject: [PATCH 6/9] test(macos): use typeKey for event jump UI test keystrokes Co-authored-by: Tyler Dane --- .../CompassUITests/EventJumpDayPrefixUITests.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift b/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift index 6cb499c68c..f356607663 100644 --- a/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift +++ b/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift @@ -7,9 +7,12 @@ final class EventJumpDayPrefixUITests: XCTestCase { app.launchArguments += ["-COMPASS_FIXTURE", "demo"] app.launch() let window = app.windows["Compass"] - XCTAssertTrue(window.waitForExistence(timeout: 8)) + XCTAssertTrue(window.waitForExistence(timeout: 15)) + XCTAssertTrue( + window.staticTexts["Morning standup"].waitForExistence(timeout: 10), + "Expected demo fixture week grid before event jump") window.click() - window.typeText("f") + window.typeKey("f", modifierFlags: []) let chips = window.otherElements["compass-event-jump-chips"] XCTAssertTrue(chips.waitForExistence(timeout: 8)) let chipLabels = window.staticTexts.matching(identifier: "compass-event-jump-chip") @@ -17,7 +20,9 @@ final class EventJumpDayPrefixUITests: XCTestCase { let label = chipLabels.element(boundBy: 0).label XCTAssertFalse(label.isEmpty) XCTAssertEqual(label, label.lowercased()) - window.typeText(label) + for character in label { + window.typeKey(String(character), modifierFlags: []) + } let probe = window.descendants(matching: .any)["compass-grid-event-focused"] XCTAssertTrue(probe.waitForExistence(timeout: 8)) let evidence = XCTAttachment(screenshot: window.screenshot()) From 1504286013ba653d103ec266e09e307cbbf37468 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 00:14:26 +0000 Subject: [PATCH 7/9] test(macos): pin week grid track for event jump UI test Co-authored-by: Tyler Dane --- .../CompassUITests/EventJumpDayPrefixUITests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift b/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift index f356607663..adf81f1dbc 100644 --- a/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift +++ b/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift @@ -4,7 +4,10 @@ final class EventJumpDayPrefixUITests: XCTestCase { @MainActor func testLowercaseDayPrefixChipFocusesEvent() { let app = XCUIApplication.compassUITest() - app.launchArguments += ["-COMPASS_FIXTURE", "demo"] + app.launchArguments += [ + "-COMPASS_FIXTURE", "demo", + "-COMPASS_UI_TEST_PIN_WEEK_GRID_TRACK", + ] app.launch() let window = app.windows["Compass"] XCTAssertTrue(window.waitForExistence(timeout: 15)) From 8c27193a75ee6430e00628876e31178abcbbc482 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 01:01:09 +0000 Subject: [PATCH 8/9] fix(macos): mirror event jump chips for XCUITest and live grid layout Co-authored-by: Tyler Dane --- ...EventJumpChipsAccessibilityProbeView.swift | 77 +++++++++++++++++++ .../Compass/NativeRootController.swift | 9 +++ .../NativeCalendarRootModel+EventJump.swift | 1 + .../CompassData/NativeCalendarRootModel.swift | 6 +- 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift diff --git a/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift b/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift new file mode 100644 index 0000000000..8a77f9ad22 --- /dev/null +++ b/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift @@ -0,0 +1,77 @@ +import AppKit +import CompassKit + +/// AppKit mirror of `EventJumpChipsOverlay`. SwiftUI chip overlays are not +/// reliably exposed to XCUITest in CI; this probe carries the same identifiers. +@MainActor +final class EventJumpChipsAccessibilityProbeView: AccessibilityProbeView { + private var chipFields: [NSTextField] = [] + + func update(hints: [EventJumpChipHint]) { + chipFields.forEach { $0.removeFromSuperview() } + chipFields = [] + + if hints.isEmpty { + isHidden = true + setAccessibilityElement(false) + setAccessibilityIdentifier(nil) + setAccessibilityLabel(nil) + return + } + + isHidden = false + setAccessibilityElement(true) + setAccessibilityHidden(false) + setAccessibilityIdentifier("compass-event-jump-chips") + setAccessibilityRole(.group) + setAccessibilityLabel("Event jump chips") + + var x: CGFloat = 0 + for hint in hints { + let field = NSTextField(labelWithString: hint.label) + field.setAccessibilityIdentifier("compass-event-jump-chip") + field.setAccessibilityLabel(hint.label) + field.setAccessibilityRole(.staticText) + field.frame = NSRect(x: x, y: 0, width: max(28, field.intrinsicContentSize.width + 8), height: 22) + addSubview(field) + chipFields.append(field) + x += field.frame.width + 6 + } + frame = NSRect(x: 200, y: 200, width: max(x, 40), height: 24) + syncAccessibilityFrame() + } +} + +@MainActor +enum EventJumpChipsAccessibilityProbe { + private static weak var probe: EventJumpChipsAccessibilityProbeView? + + static func attach(to window: NSWindow) { + guard let host = window.contentView else { return } + attach(to: host) + } + + static func attach(to hostView: NSView) { + probe = AccessibilityProbeView.attach(existing: probe, to: hostView) { + EventJumpChipsAccessibilityProbeView(frame: .zero) + } + } + + static func publish(hints: [EventJumpChipHint]) { + if probe == nil, let window = NSApp.keyWindow ?? NSApp.mainWindow { + attach(to: window) + } + guard let probe else { return } + let wasInactive = probe.isHidden + probe.update(hints: hints) + if !hints.isEmpty { + if wasInactive { + NSAccessibility.post(element: probe, notification: .created) + } + NSAccessibility.post(element: probe, notification: .focusedUIElementChanged) + } else if !wasInactive { + NSAccessibility.post(element: probe, notification: .uiElementDestroyed) + } + probe.postLayoutChanged() + } +} diff --git a/apps/calendar-macos/Compass/NativeRootController.swift b/apps/calendar-macos/Compass/NativeRootController.swift index d6b176f9cf..7634674f4e 100644 --- a/apps/calendar-macos/Compass/NativeRootController.swift +++ b/apps/calendar-macos/Compass/NativeRootController.swift @@ -85,6 +85,13 @@ final class NativeRootController: NSHostingController { model.onShortcutsLegendAccessibilityProbeChanged = { [weak self] open in Self.publish(NativeOverlayProbes.shortcutsLegend, visible: open, hostingView: self?.view) } + model.onEventJumpChipsAccessibilityProbeChanged = { [weak self] hints in + let window = Self.compassHostWindow(hostingView: self?.view) ?? NSApp.mainWindow + if let window { + EventJumpChipsAccessibilityProbe.attach(to: window) + } + EventJumpChipsAccessibilityProbe.publish(hints: hints) + } applyTheme() deepLinkRouter.onDeliver = { [weak self] url in self?.deliverDeepLink(url) @@ -138,6 +145,8 @@ final class NativeRootController: NSHostingController { ) NativeOverlayProbes.statusToast.attach(to: window) NativeOverlayProbes.shortcutsLegend.attach(to: window) + EventJumpChipsAccessibilityProbe.attach(to: window) + EventJumpChipsAccessibilityProbe.publish(hints: model.timeGridState.eventJumpHints) } } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift index 6fea81ecf1..ac95c175ff 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift @@ -126,6 +126,7 @@ extension NativeCalendarRootModel { eventJumpHintLabels = [] quickTimeSlotHintLabels = [] } + onEventJumpChipsAccessibilityProbeChanged?(eventJumpHintLabels) applyFocusPresentation() } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index 5b6efe409a..551566f9a3 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -45,6 +45,7 @@ public final class NativeCalendarRootModel { public var onRecurrenceScopeAccessibilityProbeChanged: ((Bool) -> Void)? public var onStatusToastAccessibilityProbeChanged: ((Bool) -> Void)? public var onShortcutsLegendAccessibilityProbeChanged: ((Bool) -> Void)? + public var onEventJumpChipsAccessibilityProbeChanged: (([EventJumpChipHint]) -> Void)? public var monthPickerMonth: Date public var pendingScroll: TimeGridScrollRequest? /// True for 700ms after an already-at-now teach pulse. Matches web `NOW_CUE_PULSE_MS`. @@ -1240,8 +1241,9 @@ public final class NativeCalendarRootModel { eventJumpAssignments, prefix: eventJumpSelection.dayJumpBuffer ) + let layoutCards = currentGridCards() let frameByEventId = Dictionary( - uniqueKeysWithValues: focusLayoutCards.map { ($0.eventId, $0.frame) } + uniqueKeysWithValues: layoutCards.map { ($0.eventId, $0.frame) } ) return filtered.compactMap { assignment in guard let frame = frameByEventId[assignment.eventId] else { return nil } @@ -1257,7 +1259,7 @@ public final class NativeCalendarRootModel { let calendar = EffectiveTimeZone.calendar let eventsById = Dictionary(uniqueKeysWithValues: loadedEvents.map { ($0.id.rawValue, $0) }) var targets: [DayJumpKeyAssignment.Target] = [] - for card in focusLayoutCards { + for card in currentGridCards() { guard let event = eventsById[card.eventId] else { continue } switch event.schedule { case .timed(let timed): From 7cf9a97fb6b57bb20c4f7609a02748cd212fd20b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 01:24:36 +0000 Subject: [PATCH 9/9] fix(macos): stabilize event jump chip UI test accessibility waits Co-authored-by: Tyler Dane --- .../Compass/EventJumpChipsAccessibilityProbeView.swift | 2 ++ .../CompassUITests/EventJumpDayPrefixUITests.swift | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift b/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift index 8a77f9ad22..384a908561 100644 --- a/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift +++ b/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift @@ -29,6 +29,7 @@ final class EventJumpChipsAccessibilityProbeView: AccessibilityProbeView { var x: CGFloat = 0 for hint in hints { let field = NSTextField(labelWithString: hint.label) + field.isAccessibilityElement = true field.setAccessibilityIdentifier("compass-event-jump-chip") field.setAccessibilityLabel(hint.label) field.setAccessibilityRole(.staticText) @@ -37,6 +38,7 @@ final class EventJumpChipsAccessibilityProbeView: AccessibilityProbeView { chipFields.append(field) x += field.frame.width + 6 } + setAccessibilityChildren(chipFields) frame = NSRect(x: 200, y: 200, width: max(x, 40), height: 24) syncAccessibilityFrame() } diff --git a/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift b/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift index adf81f1dbc..3f0f7368da 100644 --- a/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift +++ b/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift @@ -16,11 +16,13 @@ final class EventJumpDayPrefixUITests: XCTestCase { "Expected demo fixture week grid before event jump") window.click() window.typeKey("f", modifierFlags: []) - let chips = window.otherElements["compass-event-jump-chips"] - XCTAssertTrue(chips.waitForExistence(timeout: 8)) let chipLabels = window.staticTexts.matching(identifier: "compass-event-jump-chip") + let firstChip = chipLabels.element(boundBy: 0) + XCTAssertTrue( + firstChip.waitForExistence(timeout: 15), + "Expected lowercase event jump chip labels after pressing f") XCTAssertGreaterThan(chipLabels.count, 0) - let label = chipLabels.element(boundBy: 0).label + let label = firstChip.label XCTAssertFalse(label.isEmpty) XCTAssertEqual(label, label.lowercased()) for character in label {