diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift index ba9827cc3..4a3ec388b 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift @@ -122,7 +122,8 @@ extension NativeCalendarRootModel { schedule: draft.schedule, edge: edge.scheduleEdge, key: key, - step: step + step: step, + weekViewClamp: allDayEdgeWeekViewClamp() ) else { return false } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift index 1e4e6dbfd..b74696abb 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+FocusedEventNudge.swift @@ -32,7 +32,8 @@ extension NativeCalendarRootModel { schedule: current, edge: edge.scheduleEdge, key: key, - step: step + step: step, + weekViewClamp: allDayEdgeWeekViewClamp() ) else { return false } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index 8d1e9f84c..6104456e0 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -742,6 +742,12 @@ public final class NativeCalendarRootModel { ) ?? startOfView } + /// Week-view window for all-day edge nudge clamping (web `dayBoundary.weekDays`). + func allDayEdgeWeekViewClamp() -> (visibleStart: Date, visibleEndInclusive: Date)? { + guard viewStore.view == .week else { return nil } + return (startOfView, endOfView) + } + func visibleDateKeys() -> [String] { let calendar = EffectiveTimeZone.calendar return (0 ..< viewStore.visibleDayCount).compactMap { offset in diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift index b77e6f9db..24136310b 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/FocusedEventShiftNudgeTests.swift @@ -212,6 +212,50 @@ final class FocusedEventShiftNudgeTests: XCTestCase { XCTAssertEqual(draft.schedule.end, endBefore) } + func testWeekViewRejectsAllDaySavedEventStartEdgeNudgeOffWeek() async throws { + let (model, environment, _) = try await makeWritableAllDayEvent() + let weekStart = try XCTUnwrap(CompassDateParsing.parseInEffectiveTimeZone("2025-12-28")) + let event = try eventOnAllDay(weekStart) + try environment.localEventRepository.put(.init(id: event.id, event: event, isDemo: false)) + model.loadedEvents = [event] + model.viewStore.view = .week + model.viewStore.setAnchorDate(weekStart) + model.rebuildPresentation() + model.focusEvent(eventId: event.id.rawValue) + model.focusStore.setEdge( + eventId: event.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: true + ) + XCTAssertFalse(model.nudgeDraftOrPlace(key: "ArrowLeft", shiftKey: true, altKey: false)) + await assertFinishedNudgePersistence(model) + let stored = try XCTUnwrap(persistedEvent(environment, id: event.id)) + XCTAssertEqual(stored.schedule, event.schedule) + } + + func testWeekViewRejectsAllDayDraftStartEdgeNudgeOffWeek() async throws { + let (model, environment, _) = try await makeWritableAllDayEvent() + let weekStart = try XCTUnwrap(CompassDateParsing.parseInEffectiveTimeZone("2025-12-28")) + let event = try eventOnAllDay(weekStart) + try environment.localEventRepository.put(.init(id: event.id, event: event, isDemo: false)) + model.loadedEvents = [event] + model.viewStore.view = .week + model.viewStore.setAnchorDate(weekStart) + model.rebuildPresentation() + model.focusEvent(eventId: event.id.rawValue) + model.openKeyboardEditForFocusedEvent() + model.focusStore.setEdge( + eventId: event.id, + edge: .startDate, + announcement: "Editing start time", + isAllDay: true + ) + let scheduleBefore = try XCTUnwrap(model.draftStore.gridDraft?.schedule) + XCTAssertFalse(model.nudgeDraftOrPlace(key: "ArrowLeft", shiftKey: true, altKey: false)) + XCTAssertEqual(model.draftStore.gridDraft?.schedule, scheduleBefore) + } + func testDraftEndEdgeNudgeOnAllDayDraft() async throws { let (model, _, event) = try await makeWritableAllDayEvent() model.openKeyboardEditForFocusedEvent() @@ -240,6 +284,25 @@ final class FocusedEventShiftNudgeTests: XCTestCase { try environment.localEventRepository.fetchAll().first { $0.id == id }?.event } + private func eventOnAllDay(_ day: Date) throws -> Event { + let startKey = CompassDateParsing.formatCalendarDay(day) + let calendar = EffectiveTimeZone.calendar + let exclusiveEnd = try XCTUnwrap(calendar.date(byAdding: .day, value: 1, to: day)) + let endKey = CompassDateParsing.formatCalendarDay(exclusiveEnd) + let json = """ + { + "id": "week-clamp-allday", + "calendarId": "cal-1", + "content": { "kind": "details", "title": "Week edge", "description": "" }, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z", + "recurrence": { "kind": "single" }, + "schedule": { "kind": "allDay", "start": "\(startKey)", "end": "\(endKey)" } + } + """ + return try ContractTestDecoding.decodeJSON(json, as: Event.self) + } + private func makeWritableTimedEvent(canWrite: Bool = true) async throws -> (NativeCalendarRootModel, NativeCalendarEnvironment, Event) { diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DraftNudge.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DraftNudge.swift index 1b3e7f77a..34bfc75ee 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DraftNudge.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DraftNudge.swift @@ -197,12 +197,42 @@ public enum DraftNudge { return applyWholeEventMovement(to: schedule, movement: movement, isStartAllowed: isStartAllowed) } + /// Week view: refuse all-day edge moves that would leave the visible week + /// (web `wouldAllDayEdgeLeaveVisibleWeek`). + public static func wouldAllDayEdgeLeaveVisibleWeek( + schedule: DraftSchedule, + edge: ScheduleEdge, + daysDelta: Int, + visibleStart: Date, + visibleEndInclusive: Date + ) -> Bool { + guard schedule.kind == .allDay, daysDelta != 0 else { return false } + let calendar = EffectiveTimeZone.calendar + let edgeDay: Date + switch edge { + case .start: + edgeDay = calendar.startOfDay(for: schedule.start) + case .end: + guard let inclusiveEnd = calendar.date(byAdding: .day, value: -1, to: schedule.end) else { + return false + } + edgeDay = calendar.startOfDay(for: inclusiveEnd) + } + guard let next = calendar.date(byAdding: .day, value: daysDelta, to: edgeDay) else { + return false + } + let first = calendar.startOfDay(for: visibleStart) + let last = calendar.startOfDay(for: visibleEndInclusive) + return next < first || next > last + } + /// Single-edge keyboard nudge; mirrors web `nudgeEventEdgeDates`. public static func nudgeScheduleEdgeByKeyboard( schedule: DraftSchedule, edge: ScheduleEdge, key: String, - step: EventNudgeStep = .fine + step: EventNudgeStep = .fine, + weekViewClamp: (visibleStart: Date, visibleEndInclusive: Date)? = nil ) -> ScheduleEdgeNudgeResult? { let isAllDay = schedule.kind == .allDay guard let movement = getArrowKeyMovement(key: key, isAllDay: isAllDay, step: step) else { @@ -210,6 +240,17 @@ public enum DraftNudge { } if isAllDay { guard movement.days != 0 else { return nil } + if let weekViewClamp, + wouldAllDayEdgeLeaveVisibleWeek( + schedule: schedule, + edge: edge, + daysDelta: movement.days, + visibleStart: weekViewClamp.visibleStart, + visibleEndInclusive: weekViewClamp.visibleEndInclusive + ) + { + return nil + } return nudgeAllDayScheduleEdge(schedule: schedule, edge: edge, daysDelta: movement.days) } guard movement.minutes != 0 else { return nil } diff --git a/apps/calendar-macos/CompassKitTests/AllDayEdgeWeekClampTests.swift b/apps/calendar-macos/CompassKitTests/AllDayEdgeWeekClampTests.swift new file mode 100644 index 000000000..2d577a56d --- /dev/null +++ b/apps/calendar-macos/CompassKitTests/AllDayEdgeWeekClampTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import CompassKit + +final class AllDayEdgeWeekClampTests: XCTestCase { + func testWouldAllDayStartEdgeLeavingWeekBeforeVisibleStart() throws { + let visibleStart = try day(2026, 1, 5) + let visibleEnd = try day(2026, 1, 11) + let schedule = allDay(start: visibleStart, inclusiveEnd: visibleStart) + XCTAssertTrue( + DraftNudge.wouldAllDayEdgeLeaveVisibleWeek( + schedule: schedule, + edge: .start, + daysDelta: -1, + visibleStart: visibleStart, + visibleEndInclusive: visibleEnd + ) + ) + } + + func testWouldAllDayEndEdgeLeavingWeekAfterVisibleEnd() throws { + let visibleStart = try day(2026, 1, 5) + let visibleEnd = try day(2026, 1, 11) + let calendar = EffectiveTimeZone.calendar + let exclusiveEnd = try XCTUnwrap(calendar.date(byAdding: .day, value: 1, to: visibleEnd)) + let schedule = allDay(start: visibleStart, exclusiveEnd: exclusiveEnd) + XCTAssertTrue( + DraftNudge.wouldAllDayEdgeLeaveVisibleWeek( + schedule: schedule, + edge: .end, + daysDelta: 1, + visibleStart: visibleStart, + visibleEndInclusive: visibleEnd + ) + ) + } + + func testEdgeNudgeRejectsMoveOutsideWeekWhenClampProvided() throws { + let visibleStart = try day(2026, 1, 5) + let visibleEnd = try day(2026, 1, 11) + let schedule = allDay(start: visibleStart, inclusiveEnd: visibleStart) + let clamp = (visibleStart: visibleStart, visibleEndInclusive: visibleEnd) + XCTAssertNil( + DraftNudge.nudgeScheduleEdgeByKeyboard( + schedule: schedule, + edge: .start, + key: "ArrowLeft", + weekViewClamp: clamp + ) + ) + } + + func testEdgeNudgeAllowsMoveInsideWeekWithClamp() throws { + let visibleStart = try day(2026, 1, 5) + let visibleEnd = try day(2026, 1, 11) + let start = try day(2026, 1, 7) + let schedule = allDay(start: start, inclusiveEnd: start) + let clamp = (visibleStart: visibleStart, visibleEndInclusive: visibleEnd) + XCTAssertNotNil( + DraftNudge.nudgeScheduleEdgeByKeyboard( + schedule: schedule, + edge: .start, + key: "ArrowRight", + weekViewClamp: clamp + ) + ) + } + + private func day(_ year: Int, _ month: Int, _ day: Int) throws -> Date { + try XCTUnwrap( + EffectiveTimeZone.calendar.date(from: DateComponents(year: year, month: month, day: day)) + ) + } + + private func allDay(start: Date, inclusiveEnd: Date) -> DraftSchedule { + let calendar = EffectiveTimeZone.calendar + let exclusiveEnd = + calendar.date(byAdding: .day, value: 1, to: inclusiveEnd) ?? inclusiveEnd + return DraftSchedule(start: start, end: exclusiveEnd, kind: .allDay) + } + + private func allDay(start: Date, exclusiveEnd: Date) -> DraftSchedule { + DraftSchedule(start: start, end: exclusiveEnd, kind: .allDay) + } +}