diff --git a/docs/acceptance/shortcuts.md b/docs/acceptance/shortcuts.md index 279c1f1c79..16dd15140f 100644 --- a/docs/acceptance/shortcuts.md +++ b/docs/acceptance/shortcuts.md @@ -11,7 +11,8 @@ Use this guide to validate: - navigating between weeks in Week view (J, K, T) - opening and using the command palette (Cmd+K) - creating events with keyboard shortcuts (C, A in both Day and Week view) -- toggling the sidebar ([) +- editing events with the same keys in Day and Week (Delete, Shift+arrows, draft arrows) +- toggling the sidebar (]) - undoing with the keyboard (Cmd+Z / Ctrl+Z) - confirming that shortcuts do not fire while typing in inputs @@ -41,16 +42,20 @@ Helpful notes: | `D` | Global | Navigate to Day view | | `W` | Global | Navigate to Week view | | `Cmd+K` / `Ctrl+K` | Global | Open command palette | -| `[` | Global | Toggle sidebar | +| `]` | Global | Toggle sidebar | | `Cmd+Z` / `Ctrl+Z` | Global | Undo last event action | | `Cmd+Shift+Z` / `Ctrl+Shift+Z` | Global | Redo last undone event action | | `J` | Day view | Previous day | | `K` | Day view | Next day | | `T` | Day view | Go to today | | `I` | Day view | Focus sidebar | -| `U` | Day view | Focus calendar | +| `U` | Day view | Focus first calendar event | | `C` | Day view | Create timed event | | `A` | Day view | Create all-day event | +| `Delete` | Day view | Delete focused/hovered event | +| `Arrow keys` | Day view | Move open draft event | +| `Shift+ArrowLeft` | Day view | Move focused event to previous day | +| `Shift+ArrowRight` | Day view | Move focused event to next day | | `Shift+ArrowUp` / `Shift+ArrowDown` | Day view | Move focused timed event 15 min earlier/later | | `J` | Week view | Previous week | | `K` | Week view | Next week | @@ -60,6 +65,7 @@ Helpful notes: | `I` | Week view | Focus sidebar | | `U` | Week view | Focus first calendar event | | `Delete` | Week view | Delete focused/hovered event | +| `Arrow keys` | Week view | Move open draft event | | `Shift+ArrowLeft` | Week view | Move focused event to previous day | | `Shift+ArrowRight` | Week view | Move focused event to next day | | `Shift+ArrowUp` / `Shift+ArrowDown` | Week view | Move focused timed event 15 min earlier/later | @@ -211,41 +217,42 @@ Pressing `C` in Day view opens a new timed event form, the same behavior as `C` --- -## Scenario 8: Toggle The Sidebar ([) +## Scenario 8: Toggle The Sidebar (]) ### UX -Pressing `[` toggles the sidebar open or closed from any view. +Pressing `]` toggles the sidebar open or closed from any view. ### Steps 1. Navigate to `/week`. -2. Press `[` to close the sidebar (if open). -3. Press `[` again to reopen it. +2. Press `]` to close the sidebar (if open). +3. Press `]` again to reopen it. 4. Navigate to `/day` and repeat. ### Expected Results -- `[` toggles the sidebar in both Week view and Day view. +- `]` toggles the sidebar in both Week view and Day view. - The calendar grid expands to fill the space when the sidebar is closed. --- -## Scenario 9: Delete A Focused Event With The Keyboard (Delete, Week View) +## Scenario 9: Delete A Focused Event With The Keyboard (Delete) ### UX -Pressing Delete while an event is focused or hovered in the Week grid deletes it — equivalent to a mouse-driven delete action. +Pressing Delete while an event is focused or hovered in the Day or Week grid deletes it — equivalent to a mouse-driven delete action. ### Steps 1. Navigate to `/week`. 2. Focus or hover an event in the grid. 3. Press Delete. +4. Navigate to `/day` and repeat with a focused or hovered event. ### Expected Results -- The event is removed from the grid. +- The event is removed from the grid in both views. - An undo toast appears. --- @@ -300,7 +307,9 @@ If time is limited, run these checks before shipping shortcut-related changes: 3. `T` returns to today from any offset in both Day and Week view. 4. Cmd+K opens the command palette; Escape closes it without action. 5. `C` opens a timed event form and `A` an all-day event form, in both Day and Week view. -6. `[` toggles the sidebar in both Week and Day view. -7. Delete removes a focused/hovered event in Week view and shows an undo toast. +6. `]` toggles the sidebar in both Week and Day view. +7. Delete removes a focused/hovered event in Day and Week view and shows an undo toast. 8. Cmd+Z / Ctrl+Z undoes the last event action; Cmd+Shift+Z / Ctrl+Shift+Z redoes it. 9. No shortcuts fire inside a focused text input except Cmd+K. +10. Shift+ArrowLeft/Right move a focused event by one day in both Day and Week view. +11. Arrow keys reposition an open draft in both Day and Week view. diff --git a/docs/development/feature-file-map.md b/docs/development/feature-file-map.md index 6fe5d60631..a23c7bf40c 100644 --- a/docs/development/feature-file-map.md +++ b/docs/development/feature-file-map.md @@ -38,7 +38,8 @@ Use this document to find the first files to inspect for common Compass changes. - Day view route and content: `packages/web/src/views/Day/view` - Day view header (includes sidebar toggle control): `packages/web/src/views/Day/components/Header/Header.tsx` -- Day keyboard shortcuts (includes `[` toggle): `packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts` +- Day keyboard shortcuts: `packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts` +- Sidebar toggle (`]`): `packages/web/src/views/Week/hooks/shortcuts/useGlobalShortcuts.ts` - Day view hooks: `packages/web/src/views/Day/hooks` - Week view: `packages/web/src/views/Week` - Responsive layout controller (auto-collapse on breakpoint crossings): `packages/web/src/components/AuthenticatedLayout/useResponsiveLayout.ts` diff --git a/docs/development/testing-playbook.md b/docs/development/testing-playbook.md index 9088bbb253..8fba372fa5 100644 --- a/docs/development/testing-playbook.md +++ b/docs/development/testing-playbook.md @@ -305,7 +305,7 @@ Assertions to prefer: - when asserting presence in JSDOM for desktop-only markup (`hidden xl:flex`), use role queries that allow hidden elements where needed - verify both pathways for toggle behavior: - user interaction (header toggle button) - - keyboard interaction (`[` shortcut via view shortcut hooks) + - keyboard interaction (`]` shortcut via global shell shortcuts) ### Seeding Event Data And Client State diff --git a/docs/frontend/responsive-layout.md b/docs/frontend/responsive-layout.md index b0821f7b0a..101eea12bc 100644 --- a/docs/frontend/responsive-layout.md +++ b/docs/frontend/responsive-layout.md @@ -16,7 +16,7 @@ Breakpoints: - `SIDEBAR_AUTO_COLLAPSE_BREAKPOINT` = 1280px (Tailwind `xl`) The hook only reacts to `matchMedia` `change` events — a breakpoint crossing, -not a continuous width readout. This means a manual toggle (`[` for the +not a continuous width readout. This means a manual toggle (`]` for the sidebar) sticks until the next crossing; it does not get silently re-opened or re-closed by unrelated re-renders. diff --git a/packages/web/src/common/utils/draft/reposition-draft-by-keyboard.util.test.ts b/packages/web/src/common/utils/draft/reposition-draft-by-keyboard.util.test.ts new file mode 100644 index 0000000000..7830ef219a --- /dev/null +++ b/packages/web/src/common/utils/draft/reposition-draft-by-keyboard.util.test.ts @@ -0,0 +1,63 @@ +import dayjs from "@core/util/date/dayjs"; +import { + createGridEventDraft, + timedGridSchedule, +} from "@web/events/grid-event-draft.adapter"; +import { repositionDraftByKeyboard } from "./reposition-draft-by-keyboard.util"; +import { describe, expect, it } from "bun:test"; + +describe("repositionDraftByKeyboard", () => { + it("moves a createShortcut timed draft by 15 minutes with ArrowDown", () => { + const draft = createGridEventDraft( + timedGridSchedule( + new Date("2026-05-20T09:00:00.000"), + new Date("2026-05-20T10:00:00.000"), + ), + ); + + const next = repositionDraftByKeyboard({ + activity: "createShortcut", + draft, + key: "ArrowDown", + }); + + expect(dayjs(next?.values.schedule.start).format()).toBe( + dayjs("2026-05-20T09:15:00.000").format(), + ); + }); + + it("returns null when the next start is outside the allowed range", () => { + const draft = createGridEventDraft( + timedGridSchedule( + new Date("2026-05-20T09:00:00.000"), + new Date("2026-05-20T10:00:00.000"), + ), + ); + + const next = repositionDraftByKeyboard({ + activity: "createShortcut", + draft, + key: "ArrowRight", + isStartAllowed: () => false, + }); + + expect(next).toBeNull(); + }); + + it("returns null for activities that are not keyboard-repositionable", () => { + const draft = createGridEventDraft( + timedGridSchedule( + new Date("2026-05-20T09:00:00.000"), + new Date("2026-05-20T10:00:00.000"), + ), + ); + + expect( + repositionDraftByKeyboard({ + activity: "dnd", + draft, + key: "ArrowDown", + }), + ).toBeNull(); + }); +}); diff --git a/packages/web/src/common/utils/draft/reposition-draft-by-keyboard.util.ts b/packages/web/src/common/utils/draft/reposition-draft-by-keyboard.util.ts new file mode 100644 index 0000000000..77bab375bf --- /dev/null +++ b/packages/web/src/common/utils/draft/reposition-draft-by-keyboard.util.ts @@ -0,0 +1,67 @@ +import dayjs from "@core/util/date/dayjs"; +import { + getArrowKeyMovement, + isTimedEventInsideOneDay, +} from "@web/common/utils/event/event-nudge.util"; +import { + type GridEventDraft, + type GridScheduleDraft, +} from "@web/events/event-draft.types"; +import { replaceGridDraftSchedule } from "@web/events/grid-event-draft.adapter"; +import { type Activity_DraftEvent } from "@web/events/stores/draft.store"; + +const canRepositionDraftByKeyboard = ( + activity: Activity_DraftEvent | null | undefined, +) => + activity === "createShortcut" || + activity === "gridClick" || + activity === "keyboardEdit"; + +/** + * Moves a draft by one arrow step. Returns the updated draft when the move + * applies, otherwise null. Callers own persistence and any day-follow + * navigation (Day) or visible-range clamping (Week). + */ +export const repositionDraftByKeyboard = ({ + activity, + draft, + key, + isStartAllowed, +}: { + activity: Activity_DraftEvent | null | undefined; + draft: GridEventDraft | null | undefined; + key: string; + /** Optional bound for the draft's next start (e.g. Week visible range). */ + isStartAllowed?: (nextStart: Date) => boolean; +}): GridEventDraft | null => { + if (!canRepositionDraftByKeyboard(activity) || !draft) return null; + + const isAllDay = draft.values.schedule.kind === "allDay"; + const movement = getArrowKeyMovement(key, isAllDay); + if (!movement) return null; + + const start = dayjs(draft.values.schedule.start); + const end = dayjs(draft.values.schedule.end); + const nextStart = start + .add(movement.days, "day") + .add(movement.minutes, "minutes"); + const nextEnd = end + .add(movement.days, "day") + .add(movement.minutes, "minutes"); + + if (isStartAllowed && !isStartAllowed(nextStart.toDate())) return null; + + if (!isAllDay && !isTimedEventInsideOneDay(nextStart, nextEnd)) { + return null; + } + + const schedule: GridScheduleDraft = isAllDay + ? { kind: "allDay", start: nextStart.toDate(), end: nextEnd.toDate() } + : { + ...draft.values.schedule, + start: nextStart.toDate(), + end: nextEnd.toDate(), + }; + + return replaceGridDraftSchedule(draft, schedule); +}; diff --git a/packages/web/src/components/Sidebar/util/sidebarFocus.util.ts b/packages/web/src/components/Sidebar/util/sidebarFocus.util.ts index b0d5c9342a..e8160b634d 100644 --- a/packages/web/src/components/Sidebar/util/sidebarFocus.util.ts +++ b/packages/web/src/components/Sidebar/util/sidebarFocus.util.ts @@ -4,7 +4,7 @@ import { ID_SIDEBAR } from "@web/common/constants/web.constants"; * Focuses the month picker's tab-stoppable day (react-datepicker keeps * exactly one day at tabindex=0) so arrow keys navigate dates right away, * falling back to the first interactive element in the sidebar. - * Used by the Day and Week views' "u" shortcut. Returns whether a focus + * Used by the Day and Week views' "i" shortcut. Returns whether a focus * target was found. */ export const focusFirstSidebarItem = (): boolean => { diff --git a/packages/web/src/shortcuts/data/shortcuts.data.test.ts b/packages/web/src/shortcuts/data/shortcuts.data.test.ts index 7d6a2a9273..a8bcdfe1ad 100644 --- a/packages/web/src/shortcuts/data/shortcuts.data.test.ts +++ b/packages/web/src/shortcuts/data/shortcuts.data.test.ts @@ -124,7 +124,7 @@ describe("shortcuts.data", () => { expect(findFocus("day")?.shortcuts).toEqual([ { keys: ["i"], label: "Focus sidebar" }, - { keys: ["u"], label: "Focus calendar" }, + { keys: ["u"], label: "Focus calendar event" }, ]); expect(findFocus("week")?.shortcuts).toEqual([ { keys: ["i"], label: "Focus sidebar" }, @@ -132,48 +132,44 @@ describe("shortcuts.data", () => { ]); }); - it("includes Delete in the week Edit section", () => { - const edit = getShortcutMenuSections({ - view: "week", - isViewingCurrentPeriod: true, - }).find((section) => section.id === "edit"); - - expect(edit?.shortcuts).toContainEqual({ - keys: ["Delete"], - label: "Delete calendar event", - }); - }); - - it("lists Shift+Arrow reschedule shortcuts in the week Edit section", () => { - const edit = getShortcutMenuSections({ - view: "week", - isViewingCurrentPeriod: true, - }).find((section) => section.id === "edit"); + it("includes Delete in the day and week Edit sections", () => { + for (const view of ["day", "week"] as const) { + const edit = getShortcutMenuSections({ + view, + isViewingCurrentPeriod: true, + }).find((section) => section.id === "edit"); - expect(edit?.shortcuts).toContainEqual({ - keys: ["Shift", "ArrowLeft"], - label: "Move event to previous day (or sidebar)", - }); - expect(edit?.shortcuts).toContainEqual({ - keys: ["Shift", "ArrowRight"], - label: "Move event to next day", - }); - expect(edit?.shortcuts).toContainEqual({ - keys: ["Shift", "ArrowUp"], - label: "Move event 15 min earlier", - }); + expect(edit?.shortcuts).toContainEqual({ + keys: ["Delete"], + label: "Delete calendar event", + }); + } }); - it("lists event nudge shortcuts in the day Edit section", () => { - const edit = getShortcutMenuSections({ - view: "day", - isViewingCurrentPeriod: true, - }).find((section) => section.id === "edit"); + it("lists Shift+Arrow reschedule shortcuts in the day and week Edit sections", () => { + for (const view of ["day", "week"] as const) { + const edit = getShortcutMenuSections({ + view, + isViewingCurrentPeriod: true, + }).find((section) => section.id === "edit"); - expect(edit?.shortcuts).toContainEqual({ - keys: ["Shift", "ArrowDown"], - label: "Move event 15 min later", - }); + expect(edit?.shortcuts).toContainEqual({ + keys: ["Shift", "ArrowLeft"], + label: "Move event to previous day", + }); + expect(edit?.shortcuts).toContainEqual({ + keys: ["Shift", "ArrowRight"], + label: "Move event to next day", + }); + expect(edit?.shortcuts).toContainEqual({ + keys: ["Shift", "ArrowUp"], + label: "Move event 15 min earlier", + }); + expect(edit?.shortcuts).toContainEqual({ + keys: ["Arrow keys"], + label: "Move draft event", + }); + } }); it("labels the Other section with a sidebar toggle", () => { diff --git a/packages/web/src/shortcuts/data/shortcuts.data.ts b/packages/web/src/shortcuts/data/shortcuts.data.ts index c227eccd00..86102182e4 100644 --- a/packages/web/src/shortcuts/data/shortcuts.data.ts +++ b/packages/web/src/shortcuts/data/shortcuts.data.ts @@ -60,44 +60,28 @@ const getCreateShortcuts = (view: ShortcutMenuView): Shortcut[] => { keys: ["a"], label: "Create all-day event" }, ]; -const getFocusShortcuts = (view: ShortcutMenuView): Shortcut[] => +const getEditShortcuts = (view: ShortcutMenuView): Shortcut[] => view === "life" ? [] - : view === "day" - ? [ - { keys: ["i"], label: "Focus sidebar" }, - { keys: ["u"], label: "Focus calendar" }, - ] - : [ - { keys: ["i"], label: "Focus sidebar" }, - { keys: ["u"], label: "Focus calendar event" }, - ]; + : [ + { keys: ["Delete"], label: "Delete calendar event" }, + { keys: ["Arrow keys"], label: "Move draft event" }, + { + keys: ["Shift", "ArrowLeft"], + label: "Move event to previous day", + }, + { keys: ["Shift", "ArrowRight"], label: "Move event to next day" }, + { keys: ["Shift", "ArrowUp"], label: "Move event 15 min earlier" }, + { keys: ["Shift", "ArrowDown"], label: "Move event 15 min later" }, + ]; -const getEditShortcuts = (view: ShortcutMenuView): Shortcut[] => +const getFocusShortcuts = (view: ShortcutMenuView): Shortcut[] => view === "life" ? [] - : view === "day" - ? [ - { - keys: ["Shift", "ArrowUp"], - label: "Move event 15 min earlier", - }, - { - keys: ["Shift", "ArrowDown"], - label: "Move event 15 min later", - }, - ] - : [ - { keys: ["Delete"], label: "Delete calendar event" }, - { keys: ["Arrow keys"], label: "Move draft event" }, - { - keys: ["Shift", "ArrowLeft"], - label: "Move event to previous day (or sidebar)", - }, - { keys: ["Shift", "ArrowRight"], label: "Move event to next day" }, - { keys: ["Shift", "ArrowUp"], label: "Move event 15 min earlier" }, - { keys: ["Shift", "ArrowDown"], label: "Move event 15 min later" }, - ]; + : [ + { keys: ["i"], label: "Focus sidebar" }, + { keys: ["u"], label: "Focus calendar event" }, + ]; const getOtherShortcuts = (): Shortcut[] => [ { keys: ["]"], label: "Toggle sidebar" }, diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx index 2d3f37cae8..58883de7d5 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx @@ -29,6 +29,7 @@ import { useGridCoordinates } from "@web/grid/hooks/useGridCoordinates"; import { useGridMeasurements } from "@web/grid/hooks/useGridMeasurements"; import { dayEventQueryRange } from "@web/views/Day/hooks/events/useDayEvents"; import { useDateInView } from "@web/views/Day/hooks/navigation/useDateInView"; +import { useDateNavigation } from "@web/views/Day/hooks/navigation/useDateNavigation"; import { useDayEventNudgeShortcuts } from "@web/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts"; import { DayInteractionCoordinator } from "@web/views/Day/interaction/DayInteractionCoordinator"; import { DayCalendarBusyPeriodsLayer } from "./DayCalendarBusyPeriods"; @@ -56,6 +57,7 @@ const isDayInteractionMotionActive = () => false; export function DayCalendarGrid() { const dateInView = useDateInView(); + const { navigateToDate } = useDateNavigation(); const today = useMemo(() => dayjs(), []); const { allDayEvents, @@ -81,7 +83,11 @@ export function DayCalendarGrid() { gridRefs.mainGridRef, visibleDates, ); - useDayEventNudgeShortcuts({ timedEvents: displayedTimedEvents }); + useDayEventNudgeShortcuts({ + allDayEvents: displayedAllDayEvents, + navigateToDate, + timedEvents: displayedTimedEvents, + }); const gridDraft = useDraftStore(selectGridDraft); const calendarColumnKeys = useMemo( diff --git a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx index d46089275a..7b9c2eb132 100644 --- a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx +++ b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx @@ -1,4 +1,5 @@ import { HotkeyManager } from "@tanstack/react-hotkeys"; +import { act } from "@testing-library/react"; import { Origin } from "@core/constants/core.constants"; import { EventIdSchema } from "@core/types/domain-primitives"; import { EventScheduleSchema } from "@core/types/event.contracts"; @@ -12,19 +13,27 @@ import { toNormalizedEventQueryData } from "@web/__tests__/utils/event-query-tes import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; import { pressKey } from "@web/__tests__/utils/keyboard.test.util"; import { createCompassQueryClient } from "@web/api/query-client"; +import { ID_EVENT_FORM, ID_SIDEBAR } from "@web/common/constants/web.constants"; import { type GridEvent } from "@web/common/types/web.event.types"; import { getBrowserTimeZone } from "@web/common/utils/datetime/web.date.util"; import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; +import { + createGridEventDraft, + timedGridSchedule, +} from "@web/events/grid-event-draft.adapter"; import { eventQueryKeys } from "@web/events/queries/event.query.keys"; import { type EventRepository } from "@web/events/repositories/event.repository.types"; +import { draftActions, useDraftStore } from "@web/events/stores/draft.store"; import { dayEventRegistry } from "@web/views/Day/interaction/registry/day-event.registry"; +import { + clearHoveredDayGridEventTarget, + setHoveredDayGridEventTarget, +} from "@web/views/Day/interaction/targeting/day-event.targeting"; import { useDayEventNudgeShortcuts } from "./useDayEventNudgeShortcuts"; -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -// useUpdateEvent gates the nudge write on `EventIdSchema.safeParse(event._id)` -// succeeding, so this id must be a real ObjectId (not a readable label) or -// the mutation silently no-ops. const TIMED_EVENT_ID = "aaaaaaaaaaaaaaaaaaaaaaaa"; +const ALL_DAY_EVENT_ID = "bbbbbbbbbbbbbbbbbbbbbbbb"; const timedEvent: GridEvent = { _id: TIMED_EVENT_ID, @@ -37,6 +46,17 @@ const timedEvent: GridEvent = { user: "user-1", }; +const allDayEvent: GridEvent = { + _id: ALL_DAY_EVENT_ID, + endDate: "2026-05-21", + isAllDay: true, + origin: Origin.COMPASS, + position: gridEventDefaultPosition, + startDate: "2026-05-20", + title: "All-day event", + user: "user-1", +}; + const timedEventContract = createMockEvent({ id: EventIdSchema.parse(TIMED_EVENT_ID), content: { kind: "details", title: "Timed event", description: "" }, @@ -48,44 +68,65 @@ const timedEventContract = createMockEvent({ }), }); +const allDayEventContract = createMockEvent({ + id: EventIdSchema.parse(ALL_DAY_EVENT_ID), + content: { kind: "details", title: "All-day event", description: "" }, + schedule: EventScheduleSchema.parse({ + kind: "allDay", + start: "2026-05-20", + end: "2026-05-21", + }), +}); + const shiftKey = { keyDownInit: { shiftKey: true }, keyUpInit: { shiftKey: true }, }; -const focusCalendarTarget = (eventType: "all-day" | "timed") => { +const focusCalendarTarget = ( + eventId: string, + eventType: "all-day" | "timed", +) => { const button = document.createElement("button"); document.body.appendChild(button); dayEventRegistry.register({ element: button, - eventId: timedEvent._id!, + eventId, eventType, }); button.focus(); return button; }; -const renderNudgeShortcuts = () => { +const renderEditShortcuts = ({ + allDayEvents = [], + navigateToDate, + timedEvents = [timedEvent], +}: { + allDayEvents?: GridEvent[]; + navigateToDate?: (date: Dayjs) => void; + timedEvents?: GridEvent[]; +} = {}) => { const queryClient = createCompassQueryClient(); - // useUpdateEvent now reads the source `Event` straight from the query - // cache (editGridEventDraft needs it), rather than the seeded-events - // `initialData` default this harness normally relies on — that default - // only applies once a real query mounts, and this hook mounts none of its - // own. Register a real cache entry so `findEventInCache` can see it. + const contracts = [timedEventContract]; + if (allDayEvents.some((event) => event._id === ALL_DAY_EVENT_ID)) { + contracts.push(allDayEventContract); + } queryClient.setQueryData( eventQueryKeys.day({ source: "local", start: "2026-05-20T00:00:00.000Z", end: "2026-05-21T00:00:00.000Z", }), - toNormalizedEventQueryData([timedEventContract]), + toNormalizedEventQueryData(contracts), ); const repository: EventRepository = { list: async () => [], - getById: async () => timedEventContract, + getById: async (id) => + id === ALL_DAY_EVENT_ID ? allDayEventContract : timedEventContract, create: async () => timedEventContract, replace: async (id, input) => ({ - ...timedEventContract, + ...(id === ALL_DAY_EVENT_ID ? allDayEventContract : timedEventContract), id, content: input.content, schedule: input.schedule, @@ -101,20 +142,19 @@ const renderNudgeShortcuts = () => { renderHook( () => useDayEventNudgeShortcuts({ + allDayEvents, dependencies, - timedEvents: [timedEvent], + navigateToDate, + timedEvents, }), { - events: [timedEventContract], + events: contracts, queryClient, }, ); return { queryClient }; }; -// The mutation's write path formats the moved instant in the browser's own -// time zone (matching every other GridEventDraft-based submit path), not -// dayjs's default local-offset `.format()`. const offsetString = (date: Dayjs) => dayjs.tz(date.toDate(), getBrowserTimeZone()).format(); @@ -126,20 +166,31 @@ const getEditMutation = ( .getAll() .find((mutation) => mutation.options.mutationKey?.[2] === "replace"); +const getDeleteMutation = ( + queryClient: ReturnType, +) => + queryClient + .getMutationCache() + .getAll() + .find((mutation) => mutation.options.mutationKey?.[2] === "delete"); + beforeEach(() => { HotkeyManager.resetInstance(); + draftActions.discard(); }); afterEach(() => { cleanup(); dayEventRegistry.clear(); + clearHoveredDayGridEventTarget(); + draftActions.discard(); document.body.innerHTML = ""; }); describe("useDayEventNudgeShortcuts", () => { it("moves the focused timed event 15 minutes earlier with Shift+ArrowUp", async () => { - focusCalendarTarget("timed"); - const { queryClient } = renderNudgeShortcuts(); + focusCalendarTarget(TIMED_EVENT_ID, "timed"); + const { queryClient } = renderEditShortcuts(); pressKey("ArrowUp", shiftKey); @@ -158,8 +209,8 @@ describe("useDayEventNudgeShortcuts", () => { }); it("moves the focused timed event 15 minutes later with Shift+ArrowDown", async () => { - focusCalendarTarget("timed"); - const { queryClient } = renderNudgeShortcuts(); + focusCalendarTarget(TIMED_EVENT_ID, "timed"); + const { queryClient } = renderEditShortcuts(); pressKey("ArrowDown", shiftKey); @@ -174,9 +225,50 @@ describe("useDayEventNudgeShortcuts", () => { ); }); - it("does not move all-day events", async () => { - focusCalendarTarget("all-day"); - const { queryClient } = renderNudgeShortcuts(); + it("moves the focused timed event to the next day with Shift+ArrowRight", async () => { + const navigateToDate = mock(() => {}); + focusCalendarTarget(TIMED_EVENT_ID, "timed"); + const { queryClient } = renderEditShortcuts({ navigateToDate }); + + pressKey("ArrowRight", shiftKey); + + await waitFor(() => { + expect(getEditMutation(queryClient)).toBeDefined(); + }); + const { input } = getEditMutation(queryClient)?.state.variables as { + input: { schedule: { start: string } }; + }; + expect(input.schedule.start).toBe( + offsetString(dayjs(timedEvent.startDate).add(1, "day")), + ); + expect(navigateToDate).toHaveBeenCalled(); + }); + + it("moves the focused all-day event to the previous day with Shift+ArrowLeft", async () => { + focusCalendarTarget(ALL_DAY_EVENT_ID, "all-day"); + const { queryClient } = renderEditShortcuts({ + allDayEvents: [allDayEvent], + timedEvents: [], + }); + + pressKey("ArrowLeft", shiftKey); + + await waitFor(() => { + expect(getEditMutation(queryClient)).toBeDefined(); + }); + const { input } = getEditMutation(queryClient)?.state.variables as { + input: { schedule: { start: string; end: string } }; + }; + expect(input.schedule.start).toBe("2026-05-19"); + expect(input.schedule.end).toBe("2026-05-20"); + }); + + it("does not move all-day events with Shift+ArrowUp", async () => { + focusCalendarTarget(ALL_DAY_EVENT_ID, "all-day"); + const { queryClient } = renderEditShortcuts({ + allDayEvents: [allDayEvent], + timedEvents: [], + }); pressKey("ArrowUp", shiftKey); @@ -185,11 +277,109 @@ describe("useDayEventNudgeShortcuts", () => { }); it("does not move anything when no event is focused", async () => { - const { queryClient } = renderNudgeShortcuts(); + const { queryClient } = renderEditShortcuts(); pressKey("ArrowUp", shiftKey); await new Promise((resolve) => setTimeout(resolve, 0)); expect(getEditMutation(queryClient)).toBeUndefined(); }); + + it("deletes the focused timed calendar event with Delete", () => { + focusCalendarTarget(TIMED_EVENT_ID, "timed"); + const { queryClient } = renderEditShortcuts(); + + pressKey("Delete"); + + expect( + (getDeleteMutation(queryClient)?.state.variables as { id?: string })?.id, + ).toBe(TIMED_EVENT_ID); + }); + + it("deletes the hovered calendar event with Delete when no event is focused", () => { + const button = focusCalendarTarget(TIMED_EVENT_ID, "timed"); + button.blur(); + setHoveredDayGridEventTarget(button); + const { queryClient } = renderEditShortcuts(); + + pressKey("Delete"); + + expect( + (getDeleteMutation(queryClient)?.state.variables as { id?: string })?.id, + ).toBe(TIMED_EVENT_ID); + }); + + it("does not delete a grid event when Delete is pressed inside an open event form", () => { + focusCalendarTarget(TIMED_EVENT_ID, "timed"); + const form = document.createElement("form"); + form.setAttribute("name", ID_EVENT_FORM); + const button = document.createElement("button"); + form.appendChild(button); + document.body.appendChild(form); + button.focus(); + const { queryClient } = renderEditShortcuts(); + + pressKey("Delete", {}, button); + + expect(getDeleteMutation(queryClient)).toBeUndefined(); + }); + + it("does not delete a grid event when Delete is pressed with sidebar focus", () => { + focusCalendarTarget(TIMED_EVENT_ID, "timed"); + const sidebar = document.createElement("div"); + sidebar.id = ID_SIDEBAR; + const button = document.createElement("button"); + sidebar.appendChild(button); + document.body.appendChild(sidebar); + button.focus(); + const { queryClient } = renderEditShortcuts(); + + pressKey("Delete", {}, button); + + expect(getDeleteMutation(queryClient)).toBeUndefined(); + }); + + it("moves the active shortcut-created draft with arrow keys", () => { + const navigateToDate = mock(() => {}); + const draft = createGridEventDraft( + timedGridSchedule( + new Date("2026-05-20T09:00:00.000"), + new Date("2026-05-20T10:00:00.000"), + ), + ); + draftActions.startGridDraft({ activity: "createShortcut", draft }); + renderEditShortcuts({ navigateToDate }); + + act(() => { + pressKey("ArrowRight"); + }); + + const nextDraft = useDraftStore.getState().gridDraft; + expect(dayjs(nextDraft?.values.schedule.start).format()).toBe( + dayjs("2026-05-21T09:00:00.000").format(), + ); + expect(navigateToDate).toHaveBeenCalled(); + }); + + it("lets editable fields keep normal arrow-key behavior", () => { + const draft = createGridEventDraft( + timedGridSchedule( + new Date("2026-05-20T09:00:00.000"), + new Date("2026-05-20T10:00:00.000"), + ), + ); + draftActions.startGridDraft({ activity: "createShortcut", draft }); + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + renderEditShortcuts(); + + act(() => { + pressKey("ArrowRight", {}, input); + }); + + expect( + dayjs(useDraftStore.getState().gridDraft?.values.schedule.start).format(), + ).toBe(dayjs("2026-05-20T09:00:00.000").format()); + }); }); diff --git a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.ts b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.ts index da7741b89c..66df641e48 100644 --- a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.ts +++ b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.ts @@ -1,46 +1,183 @@ +import dayjs, { type Dayjs } from "@core/util/date/dayjs"; +import { + isEventReadOnly, + useCalendarLookup, +} from "@web/calendars/useCalendarLookup"; +import { ID_SIDEBAR } from "@web/common/constants/web.constants"; import { type GridEvent } from "@web/common/types/web.event.types"; +import { repositionDraftByKeyboard } from "@web/common/utils/draft/reposition-draft-by-keyboard.util"; import { nudgeEventFromKeyboard } from "@web/common/utils/event/event-nudge-shortcut.util"; -import { isEventFormOpen } from "@web/common/utils/form/form.util"; -import { type EventMutationDependencies } from "@web/events/mutations/useEventMutations"; +import { + isDeleteTextEditingTarget, + isEditableKeyboardTarget, + isEventFormKeyboardTarget, + isEventFormOpen, +} from "@web/common/utils/form/form.util"; +import { + type EventMutationDependencies, + useEventMutations, +} from "@web/events/mutations/useEventMutations"; import { useUpdateEvent } from "@web/events/mutations/useUpdateEvent"; -import { draftActions } from "@web/events/stores/draft.store"; +import { draftActions, useDraftStore } from "@web/events/stores/draft.store"; import { useAppShortcut } from "@web/shortcuts/useAppShortcut"; -import { getFocusedDayGridEventTarget } from "@web/views/Day/interaction/targeting/day-event.targeting"; +import { + type DayGridEventTarget, + getFirstVisibleDayGridEventTarget, + getFocusedDayGridEventTarget, + getHoveredDayGridEventTarget, +} from "@web/views/Day/interaction/targeting/day-event.targeting"; +import { deleteEventAndDiscardDraft } from "@web/views/Forms/hooks/useDeleteEvent"; + +const DRAFT_MOVEMENT_HOTKEY_OPTIONS = { + ignoreInputs: false, + preventDefault: false, + stopPropagation: false, +} as const; /** - * Shift+ArrowUp/Down moves the focused timed calendar event by 15 minutes. - * Day-view events intentionally have no Shift+ArrowLeft/Right day moves yet. + * Day-view edit shortcuts shared with Week: Delete, Shift+arrows (nudge / + * day-move), and Arrow keys to reposition an open draft. Shift+ArrowLeft/Right + * move a focused event by one day and follow that day in the Day view. + * + * Handlers close over latest render values; TanStack Hotkeys syncs the + * callback each render, so event-list refs are unnecessary. */ export function useDayEventNudgeShortcuts({ - timedEvents, + allDayEvents = [], dependencies = {}, + navigateToDate, + timedEvents, }: { + allDayEvents?: GridEvent[]; dependencies?: EventMutationDependencies; + /** Follow draft Left/Right moves so the draft stays on screen. */ + navigateToDate?: (date: Dayjs) => void; timedEvents: GridEvent[]; }) { + const calendarLookup = useCalendarLookup(); + const { delete: deleteEvent } = useEventMutations(dependencies); const updateEvent = useUpdateEvent(dependencies); - // TanStack Hotkeys syncs callbacks on every render, so this closure always - // sees the latest timedEvents (no refs needed) - const nudgeFocusedEvent = (keyboardEvent: KeyboardEvent) => { + const findCalendarEventForTarget = (target: DayGridEventTarget) => { + const events = target.eventType === "all-day" ? allDayEvents : timedEvents; + return events.find((candidate) => candidate._id === target.eventId) ?? null; + }; + + const deleteTargetedCalendarEvent = (keyboardEvent: KeyboardEvent) => { + if ( + isDeleteTextEditingTarget(keyboardEvent) || + isEventFormKeyboardTarget(keyboardEvent) + ) { + return; + } + + if (document.activeElement?.closest(`#${ID_SIDEBAR}`)) { + return; + } + + const target = + getFocusedDayGridEventTarget() ?? + getHoveredDayGridEventTarget() ?? + getFirstVisibleDayGridEventTarget(); + if (!target) return; + + const event = findCalendarEventForTarget(target); + if (!event) return; + + if ( + isEventReadOnly(calendarLookup, event.calendarId, event.isBusy ?? false) + ) { + return; + } + + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + deleteEventAndDiscardDraft(deleteEvent, event); + }; + + const moveFocusedCalendarEvent = (keyboardEvent: KeyboardEvent) => { if (isEventFormOpen()) return; + // Focused only (no hover/first-visible fallback): moving an event the + // user isn't focused on would be surprising. const target = getFocusedDayGridEventTarget(); - if (!target || target.eventType !== "timed") return; + if (!target) return; - const event = timedEvents.find( - (candidate) => candidate._id === target.eventId, - ); + const event = findCalendarEventForTarget(target); if (!event?._id) return; + if ( + isEventReadOnly(calendarLookup, event.calendarId, event.isBusy ?? false) + ) { + return; + } + + const previousStart = dayjs(event.startDate).startOf("day"); + nudgeEventFromKeyboard({ event, keyboardEvent, - onNudge: (event) => updateEvent({ event }, true), + onNudge: (nudgedEvent) => { + updateEvent({ event: nudgedEvent }, true); + const nextStart = dayjs(nudgedEvent.startDate).startOf("day"); + if (!nextStart.isSame(previousStart, "day")) { + navigateToDate?.(nextStart); + } + }, afterNudge: () => draftActions.discard(), }); }; - useAppShortcut("Shift+ArrowUp", nudgeFocusedEvent); - useAppShortcut("Shift+ArrowDown", nudgeFocusedEvent); + const moveShortcutCreatedDraft = (keyboardEvent: KeyboardEvent) => { + if (isEditableKeyboardTarget(keyboardEvent)) return; + + const { gridDraft, status } = useDraftStore.getState(); + const previousStart = gridDraft + ? dayjs(gridDraft.values.schedule.start).startOf("day") + : null; + + const nextDraft = repositionDraftByKeyboard({ + activity: status?.activity, + draft: gridDraft, + key: keyboardEvent.key, + }); + if (!nextDraft) return; + + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + draftActions.setGridDraft(nextDraft); + + const nextStart = dayjs(nextDraft.values.schedule.start).startOf("day"); + if (previousStart && !nextStart.isSame(previousStart, "day")) { + navigateToDate?.(nextStart); + } + }; + + useAppShortcut("Delete", deleteTargetedCalendarEvent, { + ignoreInputs: false, + }); + useAppShortcut( + "ArrowUp", + moveShortcutCreatedDraft, + DRAFT_MOVEMENT_HOTKEY_OPTIONS, + ); + useAppShortcut( + "ArrowDown", + moveShortcutCreatedDraft, + DRAFT_MOVEMENT_HOTKEY_OPTIONS, + ); + useAppShortcut( + "ArrowLeft", + moveShortcutCreatedDraft, + DRAFT_MOVEMENT_HOTKEY_OPTIONS, + ); + useAppShortcut( + "ArrowRight", + moveShortcutCreatedDraft, + DRAFT_MOVEMENT_HOTKEY_OPTIONS, + ); + useAppShortcut("Shift+ArrowUp", moveFocusedCalendarEvent); + useAppShortcut("Shift+ArrowDown", moveFocusedCalendarEvent); + useAppShortcut("Shift+ArrowLeft", moveFocusedCalendarEvent); + useAppShortcut("Shift+ArrowRight", moveFocusedCalendarEvent); } diff --git a/packages/web/src/views/Day/interaction/targeting/day-event.targeting.ts b/packages/web/src/views/Day/interaction/targeting/day-event.targeting.ts index 5899f62de8..e3d575ebd7 100644 --- a/packages/web/src/views/Day/interaction/targeting/day-event.targeting.ts +++ b/packages/web/src/views/Day/interaction/targeting/day-event.targeting.ts @@ -29,6 +29,9 @@ export const clearHoveredDayGridEventTarget = export const getFocusedDayGridEventTarget = dayGridEventTargeting.getFocusedGridEventTarget; +export const getHoveredDayGridEventTarget = + dayGridEventTargeting.getHoveredGridEventTarget; + export const getFirstVisibleDayGridEventTarget = dayGridEventTargeting.getFirstVisibleGridEventTarget; diff --git a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts index b5f81fab69..946bcc231e 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts +++ b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts @@ -7,10 +7,7 @@ import { useCalendarsQuery } from "@web/calendars/calendar.query"; import { getDefaultTargetCalendar } from "@web/calendars/calendar.util"; import { type PartialMouseEvent } from "@web/common/types/util.types"; import { RecurringEventUpdateScope } from "@web/common/types/web.event.types"; -import { - getArrowKeyMovement, - isTimedEventInsideOneDay, -} from "@web/common/utils/event/event-nudge.util"; +import { repositionDraftByKeyboard as applyDraftKeyboardReposition } from "@web/common/utils/draft/reposition-draft-by-keyboard.util"; import { DirtyParser } from "@web/common/utils/parse/dirty.parser"; import { type GridEventDraft, @@ -48,11 +45,6 @@ const scopeFromApplyTo = ( ? "thisAndFollowing" : "this"; -const canRepositionDraftByKeyboard = (activity: string | null | undefined) => - activity === "createShortcut" || - activity === "gridClick" || - activity === "keyboardEdit"; - export const useDraftActions = ( draftState: State_Draft_Local, setters: Setters_Draft, @@ -248,37 +240,15 @@ export const useDraftActions = ( const repositionDraftByKeyboard = useCallback( (key: string) => { - if (!canRepositionDraftByKeyboard(activity) || !draft) return false; - - const isAllDay = draft.values.schedule.kind === "allDay"; - const movement = getArrowKeyMovement(key, isAllDay); - if (!movement) return false; - - const start = dayjs(draft.values.schedule.start); - const end = dayjs(draft.values.schedule.end); - const nextStart = start - .add(movement.days, "day") - .add(movement.minutes, "minutes"); - const nextEnd = end - .add(movement.days, "day") - .add(movement.minutes, "minutes"); - - if (!isInsideVisibleWeek(nextStart)) return false; - - if (!isAllDay && !isTimedEventInsideOneDay(nextStart, nextEnd)) { - return false; - } - - const schedule: GridScheduleDraft = isAllDay - ? { kind: "allDay", start: nextStart.toDate(), end: nextEnd.toDate() } - : { - ...draft.values.schedule, - start: nextStart.toDate(), - end: nextEnd.toDate(), - }; - - setDraft(replaceGridDraftSchedule(draft, schedule)); + const nextDraft = applyDraftKeyboardReposition({ + activity, + draft, + key, + isStartAllowed: (nextStart) => isInsideVisibleWeek(dayjs(nextStart)), + }); + if (!nextDraft) return false; + setDraft(nextDraft); return true; }, [activity, draft, isInsideVisibleWeek, setDraft],