From 2d9b81f69ea750447182b63ef62794fc56885d3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 08:28:05 +0000 Subject: [PATCH 1/7] fix(macos): shift+arrow nudge for focused saved events Port CompassKit edge nudge APIs, persist focused nudges before undo, and route hardware shift-arrows ahead of draft keys without altering main router paste, welcome, or life navigation behavior. Co-authored-by: Tyler Dane --- .../Compass/NativeGridKeyboardRouter.swift | 44 ++ .../CompassData/EventEdgeNudgeBridge.swift | 46 ++ .../NativeCalendarRootModel+Draft.swift | 6 +- ...NativeCalendarRootModel+EventActions.swift | 7 +- ...eCalendarRootModel+FocusedEventNudge.swift | 135 +++++ .../CompassData/NativeCalendarRootModel.swift | 2 + .../FocusedEventShiftNudgeTests.swift | 461 ++++++++++++++++++ .../CompassKit/Calendar/DraftNudge.swift | 186 +++++++ .../FocusedEventShiftArrowKeyboardTests.swift | 355 ++++++++++++++ 9 files changed, 1237 insertions(+), 5 deletions(-) create mode 100644 apps/calendar-macos/CompassData/Sources/CompassData/EventEdgeNudgeBridge.swift create mode 100644 apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift create mode 100644 apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift create mode 100644 apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift diff --git a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index 35cf210aa2..f8b6eaba5a 100644 --- a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift +++ b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift @@ -433,6 +433,27 @@ final class NativeGridKeyboardRouter { return true } + if keyEvent.modifiers.contains(.shift), shiftArrowKeyboardName(for: keyEvent) != nil { + if model.isEventFormVisible { + return false + } + if model.draftStore.gridDraft != nil { + if handleDraftKeys(keyEvent) { return true } + return false + } + if model.focusStore.focusedEventId != nil { + if attemptShiftArrowEdgeNudge(keyEvent) { + return true + } + if let focused = model.focusedEvent(), model.isReadOnly(event: focused) { + return false + } + return false + } + if handleDraftKeys(keyEvent) { return true } + return false + } + if handleDraftKeys(keyEvent) { return true } @@ -1196,4 +1217,27 @@ final class NativeGridKeyboardRouter { return DayJumpPrefixes.dayPrefix(forShiftedLetter: letter) != nil } + private func shiftArrowKeyboardName(for keyEvent: KeyEvent) -> String? { + switch keyEvent.key { + case .named(.arrowUp): return "ArrowUp" + case .named(.arrowDown): return "ArrowDown" + case .named(.arrowLeft): return "ArrowLeft" + case .named(.arrowRight): return "ArrowRight" + default: return nil + } + } + + private func attemptShiftArrowEdgeNudge(_ keyEvent: KeyEvent) -> Bool { + guard keyEvent.modifiers.contains(.shift), + let arrow = shiftArrowKeyboardName(for: keyEvent) + else { + return false + } + return model.nudgeDraftOrPlace( + key: arrow, + shiftKey: true, + altKey: keyEvent.modifiers.contains(.option) + ) + } + } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/EventEdgeNudgeBridge.swift b/apps/calendar-macos/CompassData/Sources/CompassData/EventEdgeNudgeBridge.swift new file mode 100644 index 0000000000..0663115db3 --- /dev/null +++ b/apps/calendar-macos/CompassData/Sources/CompassData/EventEdgeNudgeBridge.swift @@ -0,0 +1,46 @@ +import CompassKit +import Foundation + +extension EventEdge { + var scheduleEdge: DraftNudge.ScheduleEdge { + switch self { + case .startDate: return .start + case .endDate: return .end + } + } + + init(_ edge: DraftNudge.ScheduleEdge) { + switch edge { + case .start: self = .startDate + case .end: self = .endDate + } + } +} + +enum EventScheduleDraftMapping { + static func eventSchedule(from draft: DraftSchedule, event: Event) -> EventSchedule { + let timeZone: IANATimeZone? = { + if case .timed(let timed) = event.schedule { return timed.timeZone } + return nil + }() + switch draft.kind { + case .timed: + return .timed( + EventSchedule_TimedPayload( + end: DateTime(rawValue: CompassDateParsing.formatLikeDayjs(draft.end)), + kind: "timed", + start: DateTime(rawValue: CompassDateParsing.formatLikeDayjs(draft.start)), + timeZone: timeZone ?? IANATimeZone(rawValue: EffectiveTimeZone.identifier) + ) + ) + case .allDay: + return .allDay( + EventSchedule_AllDayPayload( + end: CompassDateParsing.formatCalendarDay(draft.end), + kind: "allDay", + start: CompassDateParsing.formatCalendarDay(draft.start) + ) + ) + } + } +} diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift index 12c1ab0a06..5f342e81d5 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift @@ -95,9 +95,11 @@ extension NativeCalendarRootModel { if moved { rebuildPresentation() } return moved } - if focusStore.focusedEventId != nil { return false } + if focusStore.focusedEventId != nil { + return nudgeFocusedSavedEvent(key: key, altKey: altKey) + } placeTimedDraft() - return true + return draftStore.gridDraft != nil } if draftStore.gridDraft != nil { diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventActions.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventActions.swift index 6524055235..f23353bdea 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventActions.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventActions.swift @@ -266,6 +266,7 @@ extension NativeCalendarRootModel { public func undoLastChangeAndWait() async { let revision = sessionRevision + await awaitFocusedEventNudgePersistenceBeforeUndo() guard !undoStore.isRestoringHistory() else { return } guard let entry = undoStore.peekUndo() else { statusToastStore.show(id: "undo-status", message: "Nothing to undo") @@ -363,11 +364,11 @@ extension NativeCalendarRootModel { } } - private func replaceEvent(with event: Event) async { + func replaceEvent(with event: Event, scheduleBaseline: Event? = nil) async { guard case .details(let details) = event.content else { return } - let current = loadedEvents.first { $0.id == event.id } + let reference = scheduleBaseline ?? loadedEvents.first { $0.id == event.id } let scheduleUnchanged = - current.map { EventScheduleEqual.matches(event.schedule, $0.schedule) } ?? false + reference.map { EventScheduleEqual.matches(event.schedule, $0.schedule) } ?? false let input = ReplaceEventInput( calendarId: eventsStore.source == .local ? event.calendarId : nil, content: .init( diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift new file mode 100644 index 0000000000..9f022a9b1f --- /dev/null +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift @@ -0,0 +1,135 @@ +import CompassKit +import Foundation + +extension NativeCalendarRootModel { + /// Shift+arrow nudge for a focused saved event (no grid draft). + @discardableResult + func nudgeFocusedSavedEvent(key: String, altKey: Bool) -> Bool { + guard viewStore.view != .life else { return false } + guard draftStore.gridDraft == nil else { return false } + guard !isEventFormVisible else { return false } + guard let event = focusedEvent(), case .details = event.content else { return false } + guard !isReadOnly(event: event) else { return false } + guard !isFocusedSavedEventScheduleLocked(event) else { return false } + guard let current = GridEventDraftFromEvent.draftSchedule(from: event) else { return false } + + let step = DraftNudge.nudgeStepFromKeyboard(altKey: altKey) + let isStartAllowed: (Date) -> Bool = { [weak self] nextStart in + guard let self else { return false } + let calendar = EffectiveTimeZone.calendar + let start = calendar.startOfDay(for: nextStart) + let viewStart = calendar.startOfDay(for: self.startOfView) + let viewEnd = calendar.startOfDay(for: self.endOfView) + return start >= viewStart && start <= viewEnd + } + + let baseline = event + let nextSchedule: DraftSchedule + let nextEdge: EventEdge? + + if let edge = focusStore.edge { + guard let result = DraftNudge.nudgeScheduleEdgeByKeyboard( + schedule: current, + edge: edge.scheduleEdge, + key: key, + step: step + ) else { + return false + } + nextSchedule = result.schedule + nextEdge = EventEdge(result.edge) + } else { + guard let moved = DraftNudge.nudgeScheduleByKeyboard( + schedule: current, + key: key, + isStartAllowed: isStartAllowed, + step: step + ) else { + return false + } + nextSchedule = moved + nextEdge = nil + } + + let nudged = LocalEventExpansion.copy( + baseline, + schedule: EventScheduleDraftMapping.eventSchedule(from: nextSchedule, event: baseline) + ) + + if let index = loadedEvents.firstIndex(where: { $0.id == nudged.id }) { + loadedEvents[index] = nudged + } + + if let nextEdge { + focusStore.setEdge( + eventId: nudged.id, + edge: nextEdge, + announcement: focusStore.edgeAnnouncement, + isAllDay: focusStore.edgeFocusIsAllDay + ) + } + + if !undoStore.isRestoringHistory() { + if UndoStore.isUndoableRecurrence(baseline), UndoStore.isUndoableRecurrence(nudged) { + undoStore.record(.edit(id: baseline.id, before: baseline, after: nudged)) + } else { + undoStore.record(.unrecorded) + } + } + + let predecessor = focusedEventNudgePersistTask + let revision = sessionRevision + focusedEventNudgePersistSerial += 1 + let persistSerial = focusedEventNudgePersistSerial + focusedEventNudgePersistTask = Task { [weak self] in + await predecessor?.value + guard let self else { return } + guard revision == self.sessionRevision, + persistSerial == self.focusedEventNudgePersistSerial + else { return } + await self.replaceEvent(with: nudged, scheduleBaseline: baseline) + if persistSerial == self.focusedEventNudgePersistSerial { + self.focusedEventNudgePersistTask = nil + } + } + rebuildPresentation() + return true + } + + /// Undo must run after the in-flight nudge replace (and its server round-trip) so inverse order stays coherent. + func awaitFocusedEventNudgePersistenceBeforeUndo() async { + await focusedEventNudgePersistTask?.value + while eventsStore.inFlightMutations > 0 { + try? await Task.sleep(for: .milliseconds(25)) + } + } + + /// Lets unit tests wait for async replace without reaching into internal task state. + @discardableResult + public func waitForPendingFocusedEventNudgeForTests( + timeout: Duration = .seconds(3) + ) async -> Bool { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if focusedEventNudgePersistTask == nil, eventsStore.inFlightMutations == 0 { + return true + } + try? await Task.sleep(for: .milliseconds(25)) + } + return false + } + + func isFocusedSavedEventScheduleLocked(_ event: Event) -> Bool { + let isBusy: Bool = { + if case .busy = event.content { return true } + return false + }() + let lookup = CalendarLookupBuilder.build(calendars) + return GridEventAccess.isGridEventScheduleLocked( + lookup: lookup, + calendarId: event.calendarId, + isBusy: isBusy, + isProviderManaged: event.providerManaged == true + ) + } +} diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index 57fdb7d3e2..11ca75e43a 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -136,6 +136,8 @@ public final class NativeCalendarRootModel { didSet { syncSidebandWithLoadedEvents() } } private var refreshTask: Task? + var focusedEventNudgePersistTask: Task? + var focusedEventNudgePersistSerial = 0 var focusLayoutCards: [FocusLayoutCard] = [] var eventJumpHintLabels: [EventJumpChipHint] = [] var quickTimeSlotHintLabels: [QuickTimeSlotHint] = [] diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift new file mode 100644 index 0000000000..1c79299d74 --- /dev/null +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift @@ -0,0 +1,461 @@ +import CompassKit +import XCTest +@testable import CompassData + +@MainActor +final class FocusedEventShiftNudgeTests: XCTestCase { + func testFocusedSavedEventStartEdgeNudgePersistsToRepository() async throws { + let (model, environment, original) = try await makeWritableTimedEvent() + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + XCTAssertEqual(model.focusStore.edge, .startDate) + await assertFinishedNudgePersistence(model) + + let stored = try XCTUnwrap(persistedEvent(environment, id: original.id)) + if case .timed(let timed) = stored.schedule { + XCTAssertTrue(timed.start.rawValue.hasPrefix("2026-01-01T10:15:00")) + XCTAssertTrue(timed.end.rawValue.hasPrefix("2026-01-01T11:00:00")) + } else { + XCTFail("Expected timed schedule") + } + } + + func testFocusedSavedEventEndEdgeNudgePersistsToRepository() async throws { + let (model, environment, original) = try await makeWritableTimedEvent() + model.focusStore.setEdge( + eventId: original.id, + edge: .endDate, + announcement: "Editing end time", + isAllDay: false + ) + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowUp", shiftKey: true, altKey: false)) + await assertFinishedNudgePersistence(model) + + let stored = try XCTUnwrap(persistedEvent(environment, id: original.id)) + if case .timed(let timed) = stored.schedule { + XCTAssertTrue(timed.start.rawValue.hasPrefix("2026-01-01T10:00:00")) + XCTAssertTrue(timed.end.rawValue.hasPrefix("2026-01-01T10:45:00")) + } else { + XCTFail("Expected timed schedule") + } + } + + func testFocusedAllDayStartEdgeNudgePersistsToRepository() async throws { + let (model, environment, original) = try await makeWritableAllDayEvent() + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: true + ) + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowRight", shiftKey: true, altKey: false)) + await assertFinishedNudgePersistence(model) + + let stored = try XCTUnwrap(persistedEvent(environment, id: original.id)) + guard case .allDay(let schedule) = stored.schedule else { + return XCTFail("Expected all-day schedule") + } + XCTAssertEqual(schedule.start, "2026-01-01") + XCTAssertEqual(schedule.end, "2026-01-03") + } + + func testTwoRapidNudgesPersistFinalSchedule() async throws { + let (model, environment, original) = try await makeWritableTimedEvent() + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + await assertFinishedNudgePersistence(model) + + let stored = try XCTUnwrap(persistedEvent(environment, id: original.id)) + if case .timed(let timed) = stored.schedule { + XCTAssertTrue(timed.start.rawValue.hasPrefix("2026-01-01T10:30:00")) + XCTAssertTrue(timed.end.rawValue.hasPrefix("2026-01-01T11:00:00")) + } else { + XCTFail("Expected timed schedule") + } + } + + func testNudgeThenUndoRestoresPersistedSchedule() async throws { + let (model, environment, original) = try await makeWritableTimedEvent() + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + await assertFinishedNudgePersistence(model) + await model.undoLastChangeAndWait() + await assertFinishedNudgePersistence(model) + + let stored = try XCTUnwrap(persistedEvent(environment, id: original.id)) + XCTAssertEqual(stored.schedule, original.schedule) + } + + func testUndoDuringPendingNudgeWriteEventuallyRestoresSchedule() async throws { + let (model, environment, original) = try await makeWritableTimedEvent() + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + await model.undoLastChangeAndWait() + XCTAssertNotEqual(model.statusToastStore.message, "Event changed since your last edit") + await assertFinishedNudgePersistence(model) + + let stored = try XCTUnwrap(persistedEvent(environment, id: original.id)) + XCTAssertEqual(stored.schedule, original.schedule) + } + + func testShiftNudgeRejectedInLifeView() async throws { + let (model, environment, original) = try await makeWritableTimedEvent() + model.viewStore.view = .life + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + XCTAssertFalse(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + await assertFinishedNudgePersistence(model) + let stored = try XCTUnwrap(persistedEvent(environment, id: original.id)) + XCTAssertEqual(stored.schedule, original.schedule) + } + + func testUndoAfterFirstRemoteReplaceBlockedSettlesToOriginalSchedule() async throws { + let hold = FirstRemoteReplaceHold() + hold.arm() + let (model, environment, original, capture) = try await makeSignedInRemoteTimedEvent(replaceHold: hold) + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + let firstPutObserved = await hold.waitUntilFirstPutObserved() + XCTAssertTrue(firstPutObserved) + async let undo: Void = model.undoLastChangeAndWait() + try await Task.sleep(for: .milliseconds(100)) + hold.releaseFirst() + await undo + XCTAssertNotEqual(model.statusToastStore.message, "Event changed since your last edit") + await assertFinishedNudgePersistence(model) + XCTAssertGreaterThanOrEqual(hold.observedPutCount(), 2) + await model.refreshVisibleRange() + let stored = try XCTUnwrap(EventRepository(database: environment.database).fetch(id: original.id)) + XCTAssertEqual(stored.schedule, original.schedule) + if case .timed(let timed) = capture.lastInput?.schedule { + XCTAssertTrue(timed.start.rawValue.hasPrefix("2026-01-01T10:00:00")) + } else { + XCTFail("Expected undo replace payload with original start") + } + } + + func testSignedInNudgeSendsScheduleOnRemoteReplace() async throws { + let (model, _, original, captured) = try await makeSignedInRemoteTimedEvent() + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + await assertFinishedNudgePersistence(model) + let input = try XCTUnwrap(captured.lastInput) + XCTAssertNotNil(input.schedule) + if case .timed(let timed) = input.schedule { + XCTAssertTrue(timed.start.rawValue.hasPrefix("2026-01-01T10:15:00")) + } else { + XCTFail("Expected timed schedule in replace payload") + } + } + + func testReadOnlyFocusedEventRejectsShiftNudge() async throws { + let (model, environment, original) = try await makeWritableTimedEvent(canWrite: false) + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + XCTAssertFalse(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + await assertFinishedNudgePersistence(model) + let stored = try XCTUnwrap(persistedEvent(environment, id: original.id)) + XCTAssertEqual(stored.schedule, original.schedule) + } + + func testDraftStartEdgeNudgeUsesFocusedEdge() async throws { + let (model, _, event) = try await makeWritableTimedEvent(canWrite: true) + model.openKeyboardEditForFocusedEvent() + model.focusStore.setEdge( + eventId: event.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + let endBefore = model.draftStore.gridDraft?.schedule.end + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowDown", shiftKey: true, altKey: false)) + let draft = try XCTUnwrap(model.draftStore.gridDraft) + XCTAssertEqual(draft.schedule.end, endBefore) + } + + func testDraftEndEdgeNudgeOnAllDayDraft() async throws { + let (model, _, event) = try await makeWritableAllDayEvent() + model.openKeyboardEditForFocusedEvent() + model.focusStore.setEdge( + eventId: event.id, + edge: .endDate, + announcement: "Editing end time", + isAllDay: true + ) + let startBefore = model.draftStore.gridDraft?.schedule.start + XCTAssertTrue(model.nudgeDraftOrPlace(key: "ArrowRight", shiftKey: true, altKey: false)) + let draft = try XCTUnwrap(model.draftStore.gridDraft) + XCTAssertEqual(draft.schedule.start, startBefore) + } + + private func assertFinishedNudgePersistence( + _ model: NativeCalendarRootModel, + file: StaticString = #file, + line: UInt = #line + ) async { + let finished = await model.waitForPendingFocusedEventNudgeForTests() + XCTAssertTrue(finished, file: file, line: line) + } + + private func persistedEvent(_ environment: NativeCalendarEnvironment, id: EventId) throws -> Event? { + try environment.localEventRepository.fetchAll().first { $0.id == id }?.event + } + + private func makeWritableTimedEvent(canWrite: Bool = true) async throws + -> (NativeCalendarRootModel, NativeCalendarEnvironment, Event) + { + try await makeWritableEvent(canWrite: canWrite, timed: true) + } + + private func makeWritableAllDayEvent(canWrite: Bool = true) async throws + -> (NativeCalendarRootModel, NativeCalendarEnvironment, Event) + { + try await makeWritableEvent(canWrite: canWrite, timed: false) + } + + private func makeWritableEvent(canWrite: Bool, timed: Bool) async throws + -> (NativeCalendarRootModel, NativeCalendarEnvironment, Event) + { + let previous = EffectiveTimeZone.identifier + let environment = try NativeCalendarEnvironment( + appURL: URL(string: "https://compass.invalid")!, + inMemoryDatabase: true, + sessionStore: MemorySessionStore(tokens: nil) + ) + try environment.userMetadataRepository.upsert(key: DemoDataSeeder.migrationKey, value: true) + let sample = try timed + ? StoreSampleEvents.timedEvent(id: "shift-nudge-event") + : StoreSampleEvents.allDayEvent(id: "shift-nudge-allday") + let event = Event( + calendarId: sample.calendarId, + content: sample.content, + createdAt: sample.createdAt, + id: sample.id, + recurrence: sample.recurrence, + schedule: sample.schedule, + updatedAt: sample.updatedAt + ) + let calendar = CompassCalendar( + access: canWrite ? .owner : .reader, + backgroundColor: "#ffffff", + capabilities: .init( + canInviteAttendees: false, + canManage: false, + canReadAvailability: true, + canReadDetails: true, + canWatchEvents: false, + canWrite: canWrite, + conferenceKinds: [] + ), + description: "", + foregroundColor: "#000000", + id: event.calendarId.rawValue, + isActive: true, + isPrimary: false, + isVisible: true, + name: "Shared calendar", + provider: "local", + timeZone: "UTC" + ) + try environment.calendarRepository.upsert(calendars: [calendar]) + try environment.localEventRepository.put(.init(id: event.id, event: event, isDemo: false)) + let model = NativeCalendarRootModel(environment: environment) + model.viewStore.setPinnedTimeZone("UTC") + model.viewStore.anchorDate = try XCTUnwrap( + CompassDateParsing.parseInEffectiveTimeZone("2026-01-01T12:00:00.000Z") + ) + addTeardownBlock { EffectiveTimeZone.identifier = previous } + await model.start() + model.authStore.closeModal() + await model.bootstrapLocalEventsForTests() + model.focusEvent(eventId: event.id.rawValue) + return (model, environment, event) + } + + private func makeSignedInRemoteTimedEvent( + replaceHold: FirstRemoteReplaceHold? = nil + ) async throws + -> (NativeCalendarRootModel, NativeCalendarEnvironment, Event, ReplaceInputCapture) + { + let capture = ReplaceInputCapture() + let previous = EffectiveTimeZone.identifier + let session = StubURLProtocol.makeSession() + let front = Data(#"{"uid":"account-a"}"#.utf8).base64EncodedString() + let environment = try NativeCalendarEnvironment( + appURL: URL(string: "https://compass.invalid")!, + inMemoryDatabase: true, + sessionStore: MemorySessionStore(tokens: .init( + accessToken: "access", + refreshToken: "refresh", + frontToken: front + )), + urlSession: session + ) + try AuthRememberedState.markUserHasAuthenticated(repository: environment.userMetadataRepository) + try environment.userMetadataRepository.upsert(key: DemoDataSeeder.migrationKey, value: true) + try AccountCacheIdentity.record(userId: "account-a", repository: environment.userMetadataRepository) + let event = try StoreSampleEvents.timedEvent(id: "remote-shift-nudge") + let calendar = CompassCalendar( + access: .owner, + backgroundColor: "#ffffff", + capabilities: .init( + canInviteAttendees: false, + canManage: false, + canReadAvailability: true, + canReadDetails: true, + canWatchEvents: false, + canWrite: true, + conferenceKinds: [] + ), + description: "", + foregroundColor: "#000000", + id: event.calendarId.rawValue, + isActive: true, + isPrimary: true, + isVisible: true, + name: "Remote", + provider: "google", + timeZone: "UTC" + ) + try environment.calendarRepository.upsert(calendars: [calendar]) + try EventRepository(database: environment.database).upsert(events: [event], isLocal: false) + let model = NativeCalendarRootModel(environment: environment) + model.viewStore.setPinnedTimeZone("UTC") + model.viewStore.anchorDate = try XCTUnwrap( + CompassDateParsing.parseInEffectiveTimeZone("2026-01-01T12:00:00.000Z") + ) + let range = CalendarWindowMath.weekEventQueryRange(anchor: model.viewStore.anchorDate) + try RangeCache(database: environment.database).markLoaded( + key: .init(scope: .week, source: .remote, start: range.startDate, end: range.endDate), + fetchedAt: Date(timeIntervalSince1970: 0) + ) + StubURLProtocol.Handler.requestHandler = { request in + if request.httpMethod == "PUT", + request.url?.path == "/api/event/\(event.id.rawValue)" + { + if let replaceHold { + replaceHold.gateFirstPutIfNeeded() + } + let body = HTTPBodyCapture.data(from: request) + if let input = try? JSONDecoder().decode(ReplaceEventInput.self, from: body) { + capture.store(input) + } + let response = EventResponse(event: try StoreSampleEvents.response(from: event)) + return .init(statusCode: 200, body: try JSONEncoder().encode(response)) + } + throw URLError(.notConnectedToInternet) + } + addTeardownBlock { + StubURLProtocol.Handler.requestHandler = nil + session.invalidateAndCancel() + EffectiveTimeZone.identifier = previous + } + await model.start() + model.authStore.closeModal() + model.focusEvent(eventId: event.id.rawValue) + return (model, environment, event, capture) + } +} + +private final class FirstRemoteReplaceHold: @unchecked Sendable { + private let lock = NSLock() + private var armed = false + private var holdingFirst = false + private var firstPutObserved = false + private var putCount = 0 + + func arm() { lock.withLock { armed = true } } + + func gateFirstPutIfNeeded() { + let shouldSpin: Bool = lock.withLock { + putCount += 1 + if armed, putCount == 1 { + holdingFirst = true + firstPutObserved = true + return true + } + return false + } + if shouldSpin { + while lock.withLock({ holdingFirst }) { + Thread.sleep(forTimeInterval: 0.005) + } + } + } + + func waitUntilFirstPutObserved(timeout: Duration = .seconds(3)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if lock.withLock({ firstPutObserved }) { return true } + try? await Task.sleep(for: .milliseconds(25)) + } + return false + } + + func releaseFirst() { lock.withLock { holdingFirst = false } } + + func observedPutCount() -> Int { lock.withLock { putCount } } +} + +private final class ReplaceInputCapture: @unchecked Sendable { + private let lock = NSLock() + private var inputs: [ReplaceEventInput] = [] + var lastInput: ReplaceEventInput? { lock.withLock { inputs.last } } + func store(_ value: ReplaceEventInput) { lock.withLock { inputs.append(value) } } +} + +private enum HTTPBodyCapture { + static func data(from request: URLRequest) -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(contentsOf: buffer.prefix(count)) + } + return data + } +} diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DraftNudge.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DraftNudge.swift index 13f8dcbf9a..1b3e7f77a6 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DraftNudge.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DraftNudge.swift @@ -166,4 +166,190 @@ public enum DraftNudge { false } } + + /// Start or end edge for Tab edge-focus and Shift+arrow edge nudges. + public enum ScheduleEdge: Sendable, Hashable { + case start + case end + } + + public struct ScheduleEdgeNudgeResult: Sendable, Hashable { + public var schedule: DraftSchedule + public var edge: ScheduleEdge + + public init(schedule: DraftSchedule, edge: ScheduleEdge) { + self.schedule = schedule + self.edge = edge + } + } + + /// Whole-event keyboard nudge without draft-activity gating (saved grid events). + public static func nudgeScheduleByKeyboard( + schedule: DraftSchedule, + key: String, + isStartAllowed: ((Date) -> Bool)? = nil, + step: EventNudgeStep = .fine + ) -> DraftSchedule? { + let isAllDay = schedule.kind == .allDay + guard let movement = getArrowKeyMovement(key: key, isAllDay: isAllDay, step: step) else { + return nil + } + return applyWholeEventMovement(to: schedule, movement: movement, isStartAllowed: isStartAllowed) + } + + /// Single-edge keyboard nudge; mirrors web `nudgeEventEdgeDates`. + public static func nudgeScheduleEdgeByKeyboard( + schedule: DraftSchedule, + edge: ScheduleEdge, + key: String, + step: EventNudgeStep = .fine + ) -> ScheduleEdgeNudgeResult? { + let isAllDay = schedule.kind == .allDay + guard let movement = getArrowKeyMovement(key: key, isAllDay: isAllDay, step: step) else { + return nil + } + if isAllDay { + guard movement.days != 0 else { return nil } + return nudgeAllDayScheduleEdge(schedule: schedule, edge: edge, daysDelta: movement.days) + } + guard movement.minutes != 0 else { return nil } + return nudgeTimedScheduleEdge(schedule: schedule, edge: edge, minutesDelta: movement.minutes) + } + + private static func applyWholeEventMovement( + to schedule: DraftSchedule, + movement: EventNudgeMovement, + isStartAllowed: ((Date) -> Bool)? + ) -> DraftSchedule? { + let calendar = EffectiveTimeZone.calendar + var nextStart = calendar.date(byAdding: .day, value: movement.days, to: schedule.start) + ?? schedule.start + nextStart = calendar.date(byAdding: .minute, value: movement.minutes, to: nextStart) + ?? nextStart + var nextEnd = calendar.date(byAdding: .day, value: movement.days, to: schedule.end) + ?? schedule.end + nextEnd = calendar.date(byAdding: .minute, value: movement.minutes, to: nextEnd) + ?? nextEnd + + if let isStartAllowed, !isStartAllowed(nextStart) { + return nil + } + + if schedule.kind == .timed, !isTimedEventInsideOneDay(start: nextStart, end: nextEnd) { + return nil + } + + return DraftSchedule(start: nextStart, end: nextEnd, kind: schedule.kind) + } + + private static func nudgeTimedScheduleEdge( + schedule: DraftSchedule, + edge: ScheduleEdge, + minutesDelta: Int + ) -> ScheduleEdgeNudgeResult? { + let step = GridLayoutConstants.gridTimeStep + let start = schedule.start + let end = schedule.end + + switch edge { + case .start: + let candidateStart = EffectiveTimeZone.calendar.date( + byAdding: .minute, + value: minutesDelta, + to: start + ) ?? start + let latestStart = EffectiveTimeZone.calendar.date(byAdding: .minute, value: -step, to: end) + ?? end + + if candidateStart <= latestStart { + guard isTimedEventInsideOneDay(start: candidateStart, end: end) else { return nil } + let next = DraftSchedule(start: candidateStart, end: end, kind: .timed) + return ScheduleEdgeNudgeResult(schedule: next, edge: .start) + } + + let flippedEnd = EffectiveTimeZone.calendar.date(byAdding: .minute, value: step, to: end) ?? end + guard isTimedEventInsideOneDay(start: end, end: flippedEnd) else { return nil } + let next = DraftSchedule(start: end, end: flippedEnd, kind: .timed) + return ScheduleEdgeNudgeResult(schedule: next, edge: .end) + + case .end: + let candidateEnd = EffectiveTimeZone.calendar.date( + byAdding: .minute, + value: minutesDelta, + to: end + ) ?? end + let earliestEnd = EffectiveTimeZone.calendar.date(byAdding: .minute, value: step, to: start) + ?? start + + if candidateEnd >= earliestEnd { + guard isTimedEventInsideOneDay(start: start, end: candidateEnd) else { return nil } + let next = DraftSchedule(start: start, end: candidateEnd, kind: .timed) + return ScheduleEdgeNudgeResult(schedule: next, edge: .end) + } + + let flippedStart = EffectiveTimeZone.calendar.date(byAdding: .minute, value: -step, to: start) ?? start + guard isTimedEventInsideOneDay(start: flippedStart, end: start) else { return nil } + let next = DraftSchedule(start: flippedStart, end: start, kind: .timed) + return ScheduleEdgeNudgeResult(schedule: next, edge: .start) + } + } + + private static func nudgeAllDayScheduleEdge( + schedule: DraftSchedule, + edge: ScheduleEdge, + daysDelta: Int + ) -> ScheduleEdgeNudgeResult? { + let calendar = EffectiveTimeZone.calendar + let startDay = calendar.startOfDay(for: schedule.start) + guard let inclusiveEnd = calendar.date(byAdding: .day, value: -1, to: schedule.end) else { + return nil + } + let inclusiveEndDay = calendar.startOfDay(for: inclusiveEnd) + + switch edge { + case .start: + guard let candidate = calendar.date(byAdding: .day, value: daysDelta, to: startDay) else { + return nil + } + let nextStart: Date + let nextInclusiveEnd: Date + let nextEdge: ScheduleEdge + if candidate > inclusiveEndDay { + nextStart = inclusiveEndDay + nextInclusiveEnd = candidate + nextEdge = .end + } else { + nextStart = candidate + nextInclusiveEnd = inclusiveEndDay + nextEdge = .start + } + guard let exclusiveEnd = calendar.date(byAdding: .day, value: 1, to: nextInclusiveEnd) else { + return nil + } + let next = DraftSchedule(start: nextStart, end: exclusiveEnd, kind: .allDay) + return ScheduleEdgeNudgeResult(schedule: next, edge: nextEdge) + + case .end: + guard let candidate = calendar.date(byAdding: .day, value: daysDelta, to: inclusiveEndDay) else { + return nil + } + let nextStart: Date + let nextInclusiveEnd: Date + let nextEdge: ScheduleEdge + if candidate < startDay { + nextStart = candidate + nextInclusiveEnd = startDay + nextEdge = .start + } else { + nextStart = startDay + nextInclusiveEnd = candidate + nextEdge = .end + } + guard let exclusiveEnd = calendar.date(byAdding: .day, value: 1, to: nextInclusiveEnd) else { + return nil + } + let next = DraftSchedule(start: nextStart, end: exclusiveEnd, kind: .allDay) + return ScheduleEdgeNudgeResult(schedule: next, edge: nextEdge) + } + } } diff --git a/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift b/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift new file mode 100644 index 0000000000..4690b87ac2 --- /dev/null +++ b/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift @@ -0,0 +1,355 @@ +import AppKit +import CompassData +import CompassKit +import XCTest + +@MainActor +final class FocusedEventShiftArrowKeyboardTests: XCTestCase { + func testRouterShiftArrowDownNudgesFocusedTimedStartEdge() async throws { + _ = NSApplication.shared + let (model, environment, original) = try await localTimedFixture() + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + + XCTAssertEqual(model.focusStore.focusedEventId, original.id) + XCTAssertNotNil(model.focusedEvent(), "fixture must load event before router nudge") + let router = try makeGridRouter(model: model) + XCTAssertTrue(router.shiftArrow(.down), "router must handle shift+nudge for focused start edge") + let updated = try XCTUnwrap(model.event(for: original.id)) + if case .timed(let timed) = updated.schedule { + XCTAssertTrue(timed.start.rawValue.hasPrefix("2026-01-01T10:15:00")) + } else { + XCTFail("Expected timed schedule on model after nudge") + } + + await assertFinishedNudgePersistence(model) + let stored = try XCTUnwrap( + environment.localEventRepository.fetchAll().first { $0.id == original.id }?.event + ) + if case .timed(let timed) = stored.schedule { + XCTAssertTrue(timed.start.rawValue.hasPrefix("2026-01-01T10:15:00")) + XCTAssertTrue(timed.end.rawValue.hasPrefix("2026-01-01T11:00:00")) + } else { + XCTFail("Expected timed schedule in repository") + } + } + + func testRouterShiftArrowRightNudgesFocusedAllDayStartEdge() async throws { + _ = NSApplication.shared + let (model, environment, original) = try await localAllDayFixture() + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: true + ) + XCTAssertEqual(model.focusStore.focusedEventId, original.id) + XCTAssertNotNil(model.focusedEvent(), "fixture must load event before router nudge") + let router = try makeGridRouter(model: model) + XCTAssertTrue(router.shiftArrow(.right)) + await assertFinishedNudgePersistence(model) + + let stored = try XCTUnwrap( + environment.localEventRepository.fetchAll().first { $0.id == original.id }?.event + ) + guard case .allDay(let schedule) = stored.schedule else { + return XCTFail("Expected all-day schedule in repository") + } + XCTAssertEqual(schedule.start, "2026-01-01") + XCTAssertEqual(schedule.end, "2026-01-03") + } + + func testRouterShiftArrowUpNudgesFocusedTimedEndEdge() async throws { + _ = NSApplication.shared + let (model, _, original) = try await localTimedFixture() + model.focusStore.setEdge( + eventId: original.id, + edge: .endDate, + announcement: "Editing end time", + isAllDay: false + ) + XCTAssertEqual(model.focusStore.focusedEventId, original.id) + XCTAssertNotNil(model.focusedEvent(), "fixture must load event before router nudge") + let router = try makeGridRouter(model: model) + XCTAssertTrue(router.shiftArrow(.up)) + await assertFinishedNudgePersistence(model) + + let stored = try XCTUnwrap(model.event(for: original.id)) + if case .timed(let timed) = stored.schedule { + XCTAssertTrue(timed.end.rawValue.hasPrefix("2026-01-01T10:45:00")) + } else { + XCTFail("Expected timed schedule") + } + } + + func testRouterRejectsShiftArrowWhenReadOnly() async throws { + _ = NSApplication.shared + let (model, environment, original) = try await localTimedFixture(canWrite: false) + XCTAssertNotNil(model.focusedEvent(), "fixture must load event before router nudge") + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + XCTAssertEqual(model.focusStore.focusedEventId, original.id) + XCTAssertTrue(model.isReadOnly(event: original)) + let router = try makeGridRouter(model: model) + XCTAssertFalse(router.shiftArrow(.down)) + await assertFinishedNudgePersistence(model) + let stored = try XCTUnwrap( + environment.localEventRepository.fetchAll().first { $0.id == original.id }?.event + ) + XCTAssertEqual(stored.schedule, original.schedule) + } + + func testRouterIgnoresShiftArrowWhenEventFormIsOpen() async throws { + _ = NSApplication.shared + let (model, environment, original) = try await localTimedFixture() + XCTAssertNotNil(model.focusedEvent(), "fixture must load focused event before opening form") + model.openKeyboardEditForFocusedEvent() + model.openEventFormForCurrentDraft() + XCTAssertNotNil(model.draftStore.gridDraft, "keyboard edit must seed draft before form opens") + XCTAssertTrue(model.isEventFormVisible) + let router = try makeGridRouter(model: model) + XCTAssertFalse(router.shiftArrow(.down)) + await assertFinishedNudgePersistence(model) + let stored = try XCTUnwrap( + environment.localEventRepository.fetchAll().first { $0.id == original.id }?.event + ) + XCTAssertEqual(stored.schedule, original.schedule) + } + + func testRouterShiftArrowPlacesTimedDraftWhenNothingFocused() async throws { + _ = NSApplication.shared + let (model, _, _) = try await localTimedFixture() + model.focusStore.resetEdgeFocus() + XCTAssertNil(model.focusStore.focusedEventId) + XCTAssertNil(model.draftStore.gridDraft) + let router = try makeGridRouter(model: model) + XCTAssertTrue(router.shiftArrow(.down)) + XCTAssertNotNil(model.draftStore.gridDraft) + XCTAssertEqual(model.draftStore.status.activity, .keyboardPlace) + } + + func testRouterIgnoresShiftArrowWhenEditableTextViewIsFirstResponder() async throws { + _ = NSApplication.shared + let (model, environment, original) = try await localTimedFixture() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + addTeardownBlock { + window.orderOut(nil) + NSApp.keyWindow?.makeFirstResponder(nil) + } + let textView = NSTextView(frame: window.contentView!.bounds) + textView.isEditable = true + textView.string = "typing" + window.contentView?.addSubview(textView) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(textView) + XCTAssertTrue((window.firstResponder as? NSTextView)?.isEditable == true) + + model.focusStore.setEdge( + eventId: original.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: false + ) + let router = try makeGridRouter(model: model) + XCTAssertFalse(router.shiftArrow(.down, windowNumber: window.windowNumber)) + await assertFinishedNudgePersistence(model) + let stored = try XCTUnwrap( + environment.localEventRepository.fetchAll().first { $0.id == original.id }?.event + ) + XCTAssertEqual(stored.schedule, original.schedule) + } + + private func makeGridRouter(model: NativeCalendarRootModel) throws -> NativeGridKeyboardRouter { + let router = NativeGridKeyboardRouter(model: model, registry: try ShortcutRegistry()) + router.dispatcher.pushScope(.grid) + return router + } + + private static func decodeAllDayEvent(id: String) throws -> Event { + let json = """ + { + "id": "\(id)", + "calendarId": "cal-1", + "content": { "kind": "details", "title": "Offsite", "description": "" }, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z", + "recurrence": { "kind": "single" }, + "schedule": { + "kind": "allDay", + "start": "2026-01-01", + "end": "2026-01-02" + } + } + """ + return try JSONDecoder().decode(Event.self, from: Data(json.utf8)) + } + + private static func decodeTimedEvent(id: String) throws -> Event { + let json = """ + { + "id": "\(id)", + "calendarId": "cal-1", + "content": { "kind": "details", "title": "Meet", "description": "" }, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z", + "recurrence": { "kind": "single" }, + "schedule": { + "kind": "timed", + "start": "2026-01-01T10:00:00.000Z", + "end": "2026-01-01T11:00:00.000Z", + "timeZone": "UTC" + } + } + """ + return try JSONDecoder().decode(Event.self, from: Data(json.utf8)) + } + + private func localAllDayFixture() async throws + -> (NativeCalendarRootModel, NativeCalendarEnvironment, Event) + { + try await localFixture(sample: try Self.decodeAllDayEvent(id: "router-local-allday")) + } + + private func localTimedFixture(canWrite: Bool = true) async throws + -> (NativeCalendarRootModel, NativeCalendarEnvironment, Event) + { + try await localFixture(sample: try Self.decodeTimedEvent(id: "router-local-event"), canWrite: canWrite) + } + + private func assertFinishedNudgePersistence( + _ model: NativeCalendarRootModel, + file: StaticString = #filePath, + line: UInt = #line + ) async { + let finished = await model.waitForPendingFocusedEventNudgeForTests() + XCTAssertTrue(finished, file: (file), line: line) + } + + private func localFixture(sample: Event, canWrite: Bool = true) async throws + -> (NativeCalendarRootModel, NativeCalendarEnvironment, Event) + { + let environment = try NativeCalendarEnvironment( + appURL: URL(string: "https://compass.invalid")!, + inMemoryDatabase: true, + sessionStore: MemorySessionStore(tokens: nil) + ) + try environment.userMetadataRepository.upsert(key: DemoDataSeeder.migrationKey, value: true) + let calendar = CompassCalendar( + access: canWrite ? .owner : .reader, + backgroundColor: "#ffffff", + capabilities: .init( + canInviteAttendees: false, + canManage: false, + canReadAvailability: true, + canReadDetails: true, + canWatchEvents: false, + canWrite: canWrite, + conferenceKinds: [] + ), + description: "", + foregroundColor: "#000000", + id: sample.calendarId.rawValue, + isActive: true, + isPrimary: false, + isVisible: true, + name: "Shared", + provider: "local", + timeZone: "UTC" + ) + try environment.calendarRepository.upsert(calendars: [calendar]) + let event = Event( + calendarId: sample.calendarId, + content: sample.content, + createdAt: sample.createdAt, + id: sample.id, + recurrence: sample.recurrence, + schedule: sample.schedule, + updatedAt: sample.updatedAt + ) + try environment.localEventRepository.put(.init(id: event.id, event: event, isDemo: false)) + let previousTimeZone = EffectiveTimeZone.identifier + EffectiveTimeZone.identifier = "UTC" + addTeardownBlock { EffectiveTimeZone.identifier = previousTimeZone } + let model = NativeCalendarRootModel(environment: environment) + model.viewStore.setPinnedTimeZone("UTC") + model.viewStore.anchorDate = try XCTUnwrap( + CompassDateParsing.parseInEffectiveTimeZone("2026-01-01T12:00:00.000Z") + ) + await model.start() + model.authStore.closeModal() + let hasSeenKey = OnboardingStorageKeys.hasSeenWelcome + let exitKey = OnboardingStorageKeys.welcomeExit + let priorHasSeen = UserDefaults.standard.string(forKey: hasSeenKey) + let priorExit = UserDefaults.standard.string(forKey: exitKey) + addTeardownBlock { + if let priorHasSeen { + UserDefaults.standard.set(priorHasSeen, forKey: hasSeenKey) + } else { + UserDefaults.standard.removeObject(forKey: hasSeenKey) + } + if let priorExit { + UserDefaults.standard.set(priorExit, forKey: exitKey) + } else { + UserDefaults.standard.removeObject(forKey: exitKey) + } + } + model.onboardingStore.markWelcomeSeen(exit: "test") + model.onboardingStore.setWelcomeFirstVisitOpen(false) + NSApp.keyWindow?.makeFirstResponder(nil) + await model.bootstrapLocalEventsForTests() + if canWrite { + XCTAssertFalse(model.isReadOnly(event: event)) + } + XCTAssertNotNil( + model.event(for: event.id), + "bootstrap must load local event into model" + ) + model.focusGridEvent(eventId: event.id.rawValue) + XCTAssertEqual(model.focusStore.focusedEventId, event.id) + return (model, environment, event) + } +} + +private extension NativeGridKeyboardRouter { + enum Arrow { + case up, down, left, right + } + + func shiftArrow(_ arrow: Arrow, windowNumber: Int = 0) -> Bool { + let keyCode: UInt16 = switch arrow { + case .up: 126 + case .down: 125 + case .left: 123 + case .right: 124 + } + guard let keyDown = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: .shift, + timestamp: 0, + windowNumber: windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode + ) else { + return false + } + return handleKeyDown(keyDown) + } +} From 916f65b09c517abc3677802a745407d2f08ac99a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 08:31:21 +0000 Subject: [PATCH 2/7] fix(macos): use editDraft schedule for focused edge nudge Co-authored-by: Tyler Dane --- .../CompassData/NativeCalendarRootModel+FocusedEventNudge.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift index 9f022a9b1f..1e4e6dbfde 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift @@ -11,7 +11,7 @@ extension NativeCalendarRootModel { guard let event = focusedEvent(), case .details = event.content else { return false } guard !isReadOnly(event: event) else { return false } guard !isFocusedSavedEventScheduleLocked(event) else { return false } - guard let current = GridEventDraftFromEvent.draftSchedule(from: event) else { return false } + guard let current = GridEventDraftFromEvent.editDraft(from: event)?.schedule else { return false } let step = DraftNudge.nudgeStepFromKeyboard(altKey: altKey) let isStartAllowed: (Date) -> Bool = { [weak self] nextStart in From c436ac3a62251b86cd0e128a80a1e2321c5f9b85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 08:35:00 +0000 Subject: [PATCH 3/7] test(macos): align focused nudge tests with main fixtures Co-authored-by: Tyler Dane --- .../FocusedEventShiftNudgeTests.swift | 3 ++- .../CompassDataTests/StoreTestsSupport.swift | 23 +++++++++++++++++++ .../FocusedEventShiftArrowKeyboardTests.swift | 3 ++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift index 1c79299d74..b77e6f9db8 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift @@ -306,7 +306,8 @@ final class FocusedEventShiftNudgeTests: XCTestCase { addTeardownBlock { EffectiveTimeZone.identifier = previous } await model.start() model.authStore.closeModal() - await model.bootstrapLocalEventsForTests() + model.loadedEvents = [event] + model.updateContentTrackWidth(1200) model.focusEvent(eventId: event.id.rawValue) return (model, environment, event) } diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/StoreTestsSupport.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/StoreTestsSupport.swift index 3212701aff..72de6ab6f4 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/StoreTestsSupport.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/StoreTestsSupport.swift @@ -148,6 +148,29 @@ enum StoreSampleEvents { return try ContractTestDecoding.decodeJSON(json, as: Event.self) } + static func allDayEvent(id: String, title: String = "All day") throws -> Event { + let json = """ + { + "id": "\(id)", + "calendarId": "cal-1", + "content": { + "kind": "details", + "title": "\(title)", + "description": "" + }, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z", + "recurrence": { "kind": "single" }, + "schedule": { + "kind": "allDay", + "start": "2026-01-01", + "end": "2026-01-02" + } + } + """ + return try ContractTestDecoding.decodeJSON(json, as: Event.self) + } + static func seriesEvent(id: String) throws -> Event { let json = """ { diff --git a/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift b/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift index 4690b87ac2..c67d312618 100644 --- a/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift +++ b/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift @@ -310,7 +310,8 @@ final class FocusedEventShiftArrowKeyboardTests: XCTestCase { model.onboardingStore.markWelcomeSeen(exit: "test") model.onboardingStore.setWelcomeFirstVisitOpen(false) NSApp.keyWindow?.makeFirstResponder(nil) - await model.bootstrapLocalEventsForTests() + model.loadedEvents = [event] + model.updateContentTrackWidth(1200) if canWrite { XCTAssertFalse(model.isReadOnly(event: event)) } From c3c4eebe194c7bd9187c02cd6c122c39f39ec564 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 08:40:46 +0000 Subject: [PATCH 4/7] test(macos): import CompassData testably in shift-arrow keyboard tests Co-authored-by: Tyler Dane --- .../CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift b/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift index c67d312618..956db4ae85 100644 --- a/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift +++ b/apps/calendar-macos/CompassNativeTests/FocusedEventShiftArrowKeyboardTests.swift @@ -2,6 +2,7 @@ import AppKit import CompassData import CompassKit import XCTest +@testable import CompassData @MainActor final class FocusedEventShiftArrowKeyboardTests: XCTestCase { From f0370772af3a1aa292e02c724b829bec07847ccc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 08:48:41 +0000 Subject: [PATCH 5/7] fix(macos): skip shift-arrow nudge while native text field focused Co-authored-by: Tyler Dane --- apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index f8b6eaba5a..5cc1666986 100644 --- a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift +++ b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift @@ -434,6 +434,9 @@ final class NativeGridKeyboardRouter { } if keyEvent.modifiers.contains(.shift), shiftArrowKeyboardName(for: keyEvent) != nil { + if dispatcher.isTextInputFocused { + return false + } if model.isEventFormVisible { return false } From 769b1ea7a1234fbe58fec1d1f019125a4d09449e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 08:53:37 +0000 Subject: [PATCH 6/7] fix(macos): respect event window text focus for shift nudge Co-authored-by: Tyler Dane --- apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index 5cc1666986..a5752f7372 100644 --- a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift +++ b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift @@ -434,7 +434,10 @@ final class NativeGridKeyboardRouter { } if keyEvent.modifiers.contains(.shift), shiftArrowKeyboardName(for: keyEvent) != nil { - if dispatcher.isTextInputFocused { + let keyWindow = event.window ?? NSApp.keyWindow + if (keyWindow?.firstResponder as? NSTextView)?.isEditable == true + || dispatcher.isTextInputFocused + { return false } if model.isEventFormVisible { From 4b03026288951395da4d64dc4dbecfd499d35b50 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 09:51:36 +0000 Subject: [PATCH 7/7] fix(macos): edge nudge keyboard edit drafts with focused edge Route grid edit drafts through DraftNudge.nudgeScheduleEdgeByKeyboard when focusStore has a start/end edge, matching web shift+arrow edge behavior. Co-authored-by: Tyler Dane --- .../NativeCalendarRootModel+Draft.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift index 5f342e81d5..ba9827cc37 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift @@ -112,6 +112,30 @@ extension NativeCalendarRootModel { private func nudgeDraft(key: String, altKey: Bool) -> Bool { guard let draft = draftStore.gridDraft, !isScheduleLocked(for: draft) else { return false } + + if let edge = focusStore.edge, + let focusedId = focusStore.focusedEventId, + focusedId == draft.clientId || focusedId == draft.sourceEventId + { + let step = DraftNudge.nudgeStepFromKeyboard(altKey: altKey) + guard let result = DraftNudge.nudgeScheduleEdgeByKeyboard( + schedule: draft.schedule, + edge: edge.scheduleEdge, + key: key, + step: step + ) else { + return false + } + draftStore.setGridSchedule(result.schedule) + focusStore.setEdge( + eventId: focusedId, + edge: EventEdge(result.edge), + announcement: focusStore.edgeAnnouncement, + isAllDay: focusStore.edgeFocusIsAllDay + ) + return true + } + return draftStore.nudgeByKeyboard( key: key, altKey: altKey,