diff --git a/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift b/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift new file mode 100644 index 000000000..384a90856 --- /dev/null +++ b/apps/calendar-macos/Compass/EventJumpChipsAccessibilityProbeView.swift @@ -0,0 +1,79 @@ +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.isAccessibilityElement = true + 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 + } + setAccessibilityChildren(chipFields) + 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/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index 396d87f49..02b15d223 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, ] @@ -366,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 } @@ -596,8 +612,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/Compass/NativeRootController.swift b/apps/calendar-macos/Compass/NativeRootController.swift index d6b176f9c..7634674f4 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 8f8bea79a..ac95c175f 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() { @@ -44,6 +126,7 @@ extension NativeCalendarRootModel { eventJumpHintLabels = [] quickTimeSlotHintLabels = [] } + onEventJumpChipsAccessibilityProbeChanged?(eventJumpHintLabels) applyFocusPresentation() } @@ -100,7 +183,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 0708ee417..551566f9a 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`. @@ -120,6 +121,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 +677,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 +799,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 +1235,64 @@ 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 layoutCards = currentGridCards() + let frameByEventId = Dictionary( + uniqueKeysWithValues: layoutCards.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 currentGridCards() { + 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 c50f39da8..d677cbd96 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 05ff9db1f..6cf4f7b2d 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 000000000..f37f85d5c --- /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 + 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 000000000..20ad75394 --- /dev/null +++ b/apps/calendar-macos/CompassKitTests/DayJumpKeyAssignmentTests.swift @@ -0,0 +1,50 @@ +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( + (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") + 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", "2026-08-08"], 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 daccd1b3e..4d9548552 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 000000000..3f0f7368d --- /dev/null +++ b/apps/calendar-macos/CompassUITests/EventJumpDayPrefixUITests.swift @@ -0,0 +1,38 @@ +import XCTest + +final class EventJumpDayPrefixUITests: XCTestCase { + @MainActor + func testLowercaseDayPrefixChipFocusesEvent() { + let app = XCUIApplication.compassUITest() + app.launchArguments += [ + "-COMPASS_FIXTURE", "demo", + "-COMPASS_UI_TEST_PIN_WEEK_GRID_TRACK", + ] + app.launch() + let window = app.windows["Compass"] + XCTAssertTrue(window.waitForExistence(timeout: 15)) + XCTAssertTrue( + window.staticTexts["Morning standup"].waitForExistence(timeout: 10), + "Expected demo fixture week grid before event jump") + window.click() + window.typeKey("f", modifierFlags: []) + 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 = firstChip.label + XCTAssertFalse(label.isEmpty) + XCTAssertEqual(label, label.lowercased()) + 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()) + evidence.name = "Day-prefix event jump chips" + evidence.lifetime = .keepAlways + add(evidence) + } +}