diff --git a/e2e/timed/keyboard-only-mode.spec.ts b/e2e/timed/keyboard-only-mode.spec.ts index 1935e9189f..57fd49049d 100644 --- a/e2e/timed/keyboard-only-mode.spec.ts +++ b/e2e/timed/keyboard-only-mode.spec.ts @@ -41,8 +41,8 @@ test("SHIFT-SHIFT enters keyboard-only mode; clicks are inert until Escape", asy await tapShift(page); await tapShift(page); - await expect(keyboardOnlyIndicator(page)).toContainText("Keyboard only"); - await expect(keyboardOnlyIndicator(page)).toContainText("Esc or Shift Shift"); + await expect(keyboardOnlyIndicator(page)).toContainText("Hardcore Mode"); + await expect(keyboardOnlyIndicator(page)).toContainText("Esc"); // Shortcuts overlay stays mounted with role=dialog even when closed, so // assert the event form did not open rather than dialog count. diff --git a/packages/web/src/common/utils/event/event-nudge.util.test.ts b/packages/web/src/common/utils/event/event-nudge.util.test.ts index 8a3ebeb479..1a7c381e47 100644 --- a/packages/web/src/common/utils/event/event-nudge.util.test.ts +++ b/packages/web/src/common/utils/event/event-nudge.util.test.ts @@ -1,5 +1,6 @@ import dayjs from "@core/util/date/dayjs"; import { + convertAllDayToTimedDates, getArrowKeyMovement, isTimedEventFullCalendarDay, isTimedEventInsideOneDay, @@ -42,6 +43,28 @@ describe("getArrowKeyMovement", () => { }); }); +describe("convertAllDayToTimedDates", () => { + it("places the event at the given start minute on its start day with a 60-minute duration", () => { + const result = convertAllDayToTimedDates( + { startDate: "2026-05-20" }, + 9 * 60, + ); + + expect(result.startDate).toStartWith("2026-05-20T09:00:00"); + expect(result.endDate).toStartWith("2026-05-20T10:00:00"); + }); + + it("only reads the event's start day, so a multi-day span collapses onto it", () => { + const result = convertAllDayToTimedDates( + { startDate: "2026-05-20" }, + 13 * 60 + 30, + ); + + expect(result.startDate).toStartWith("2026-05-20T13:30:00"); + expect(result.endDate).toStartWith("2026-05-20T14:30:00"); + }); +}); + describe("nudgeEventDates", () => { const timedEvent = { startDate: "2026-05-20T10:00:00", diff --git a/packages/web/src/common/utils/event/event-nudge.util.ts b/packages/web/src/common/utils/event/event-nudge.util.ts index 1108bb794a..3cb202338c 100644 --- a/packages/web/src/common/utils/event/event-nudge.util.ts +++ b/packages/web/src/common/utils/event/event-nudge.util.ts @@ -29,6 +29,31 @@ export const getArrowKeyMovement = ( } }; +// Mirrors CROSS_ROW_TIMED_DURATION_MIN (grid/interaction/math/cross-row.drag.ts), +// the duration invented when a mouse drag converts an all-day event into the +// timed grid. Kept as a local constant to avoid a common-utils -> grid/interaction +// dependency for one shared number. +const CONVERTED_TIMED_DURATION_MIN = 60; + +/** + * All-day -> timed via Shift+ArrowDown. Mirrors the drag conversion: start of + * day plus a caller-supplied visible start minute, fixed duration. A + * multi-day span collapses onto its start day - the keyboard has no drop + * column to say otherwise. + */ +export const convertAllDayToTimedDates = ( + event: Pick, + startMinute: number, +): { startDate: string; endDate: string } => { + const start = dayjs(event.startDate) + .startOf("day") + .add(startMinute, "minute"); + return { + startDate: start.format(), + endDate: start.add(CONVERTED_TIMED_DURATION_MIN, "minute").format(), + }; +}; + export const isTimedEventInsideOneDay = (start: Dayjs, end: Dayjs) => { const midnightAfterStart = start.add(1, "day").startOf("day"); diff --git a/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts b/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts index c0663ea796..6bb6666498 100644 --- a/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts +++ b/packages/web/src/components/CommandPalette/navigation.cmd.constants.test.ts @@ -25,7 +25,7 @@ describe("getNavigationCommandItems", () => { "Go to Week", "Go to Life", "Show shortcuts", - "Toggle keyboard-only mode", + "Toggle Hardcore Mode", ]); }); @@ -52,7 +52,7 @@ describe("getNavigationCommandItems", () => { "Go to Day", "Go to Week", "Go to Life", - "Toggle keyboard-only mode", + "Toggle Hardcore Mode", ]); }); @@ -77,11 +77,7 @@ describe("getNavigationCommandItems", () => { onNavigateToView: () => {}, }).map((item) => item.label); - expect(labels).toEqual([ - "Go to Day", - "Go to Week", - "Toggle keyboard-only mode", - ]); + expect(labels).toEqual(["Go to Day", "Go to Week", "Toggle Hardcore Mode"]); }); it("runs the matching navigation callbacks", () => { @@ -137,7 +133,7 @@ describe("getNavigationCommandItems", () => { onNavigateToView: () => {}, }).find((entry) => entry.id === "enter-keyboard-only"); - expect(item?.label).toBe("Toggle keyboard-only mode"); + expect(item?.label).toBe("Toggle Hardcore Mode"); expect(item?.shortcut).toEqual(["Shift", "Shift"]); }); }); diff --git a/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts b/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts index cee2c3c843..a2ace2d564 100644 --- a/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts +++ b/packages/web/src/components/CommandPalette/navigation.cmd.constants.ts @@ -124,10 +124,17 @@ export const getNavigationCommandItems = ({ calendarItems.push({ id: "enter-keyboard-only", - label: "Toggle keyboard-only mode", + label: "Toggle Hardcore Mode", icon: KeyboardIcon, shortcut: ["Shift", "Shift"], - keywords: ["keyboard", "clicks", "pointer", "mouseless", "hotkeys"], + keywords: [ + "keyboard", + "hardcore", + "clicks", + "pointer", + "mouseless", + "hotkeys", + ], // Defer so the palette closes before click-blocking installs. onClick: () => queueMicrotask(() => { diff --git a/packages/web/src/components/DemoEventsBanner/DemoEventsBanner.tsx b/packages/web/src/components/DemoEventsBanner/DemoEventsBanner.tsx index f206bf63ee..26c8205beb 100644 --- a/packages/web/src/components/DemoEventsBanner/DemoEventsBanner.tsx +++ b/packages/web/src/components/DemoEventsBanner/DemoEventsBanner.tsx @@ -28,8 +28,7 @@ export const DemoEventsBanner: FC = ({ onDismiss }) => ( role="status" > - Sample events to help you explore. Edit yourself or clear all from the cmd - palette. + Sample events to help you explore. Edit or clear from the cmd palette. @@ -129,9 +148,10 @@ export function WelcomeModal() { diff --git a/packages/web/src/grid/shortcuts/useGridEventEditShortcuts.ts b/packages/web/src/grid/shortcuts/useGridEventEditShortcuts.ts index 530916edb5..dbbca74d41 100644 --- a/packages/web/src/grid/shortcuts/useGridEventEditShortcuts.ts +++ b/packages/web/src/grid/shortcuts/useGridEventEditShortcuts.ts @@ -8,7 +8,10 @@ import { } from "@web/calendars/useCalendarLookup"; import { ID_SIDEBAR } from "@web/common/constants/web.constants"; import { type GridEvent } from "@web/common/types/web.event.types"; +import { getVisibleGridStartMinute } from "@web/common/utils/draft/draft.util"; +import { refocusEventElement } from "@web/common/utils/event/event.util"; import { + convertAllDayToTimedDates, type EventEdge, getArrowKeyMovement, } from "@web/common/utils/event/event-nudge.util"; @@ -47,6 +50,9 @@ import { import { useAppShortcut } from "@web/shortcuts/useAppShortcut"; import { deleteEventAndDiscardDraft } from "@web/views/Forms/hooks/useDeleteEvent"; +// Fallback when the grid can't be measured, matching EventForm's all-day->timed toggle. +const DEFAULT_TIMED_START_MINUTE = 9 * 60; + const DRAFT_MOVEMENT_HOTKEY_OPTIONS = { ignoreInputs: false, preventDefault: false, @@ -250,6 +256,19 @@ export function useGridEventEditShortcuts({ return; } + if (event.isAllDay && keyboardEvent.key === "ArrowDown") { + if (!event._id) return; + keyboardEvent.preventDefault(); + const startMinute = + getVisibleGridStartMinute() ?? DEFAULT_TIMED_START_MINUTE; + const dates = convertAllDayToTimedDates(event, startMinute); + updateEvent({ event: { ...event, ...dates, isAllDay: false } }, true, { + onOptimisticApplied: () => draftActions.discard(), + }); + refocusEventElement(event._id); + return; + } + const movement = getArrowKeyMovement( keyboardEvent.key, Boolean(event.isAllDay), diff --git a/packages/web/src/shortcuts/data/shortcuts.data.test.ts b/packages/web/src/shortcuts/data/shortcuts.data.test.ts index a19edaea75..0b01709f1b 100644 --- a/packages/web/src/shortcuts/data/shortcuts.data.test.ts +++ b/packages/web/src/shortcuts/data/shortcuts.data.test.ts @@ -278,7 +278,7 @@ describe("shortcuts.data", () => { }); expect(stripMetadata(other?.shortcuts ?? [])).toContainEqual({ keys: ["Shift", "Shift"], - label: "Toggle keyboard-only mode", + label: "Toggle Hardcore Mode", }); expect(stripMetadata(other?.shortcuts ?? [])).toContainEqual({ keys: ["Mod", "Shift", "Z"], diff --git a/packages/web/src/shortcuts/keyboard-only/KeyboardOnlyIndicator.tsx b/packages/web/src/shortcuts/keyboard-only/KeyboardOnlyIndicator.tsx index 7e997e45ab..30c59e3bb2 100644 --- a/packages/web/src/shortcuts/keyboard-only/KeyboardOnlyIndicator.tsx +++ b/packages/web/src/shortcuts/keyboard-only/KeyboardOnlyIndicator.tsx @@ -30,7 +30,7 @@ export const KeyboardOnlyIndicator: FC = () => { data-keyboard-only-indicator="" role="status" > - Keyboard only · Esc or Shift Shift + Hardcore Mode · Esc ); }; diff --git a/packages/web/src/shortcuts/shift-hint/EventJumpIndicator.tsx b/packages/web/src/shortcuts/shift-hint/EventJumpIndicator.tsx index f74643a402..a67417d864 100644 --- a/packages/web/src/shortcuts/shift-hint/EventJumpIndicator.tsx +++ b/packages/web/src/shortcuts/shift-hint/EventJumpIndicator.tsx @@ -32,7 +32,7 @@ export const EventJumpIndicator: FC = () => { ? announcement : announcement && announcement !== "Event jump on" ? `Jump · ${announcement} · Esc` - : "Event jump · Esc or Shift"; + : "Event jump · Esc"; return ( - id === null - ? null - : (getShortcutTips().find((tip) => tip.id === id)?.text ?? null); +const getTip = (id: ShortcutTipId | null): ShortcutTip | null => + id === null ? null : (getShortcutTips().find((tip) => tip.id === id) ?? null); /** * Quiet, single-line rotation in the sidebar status bar. Pure display: the @@ -22,9 +23,11 @@ const getTipText = (id: ShortcutTipId | null): string | null => */ export const ShortcutTipIndicator: FC = () => { const activeTipId = useShortcutTipsStore(selectActiveShortcutTipId); - const text = getTipText(activeTipId); + const tip = getTip(activeTipId); - if (!text || !activeTipId) return null; + if (!tip || !activeTipId) return null; + + const plainText = getTipPlainText(tip); const onMute = () => { track("shortcut_tip_acted_on", { tip: activeTipId, action: "muted" }); @@ -33,14 +36,25 @@ export const ShortcutTipIndicator: FC = () => { return ( ); diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts b/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts new file mode 100644 index 0000000000..06c7d34d12 --- /dev/null +++ b/packages/web/src/shortcuts/tips/shortcut-tips.data.test.ts @@ -0,0 +1,26 @@ +import { + getShortcutTips, + getTipPlainText, +} from "@web/shortcuts/tips/shortcut-tips.data"; +import { describe, expect, it } from "bun:test"; + +describe("getTipPlainText", () => { + it("reconstitutes each tip's full sentence from its parts", () => { + const plainTextById = Object.fromEntries( + getShortcutTips().map((tip) => [tip.id, getTipPlainText(tip)]), + ); + + expect(plainTextById["edit-sequence"]).toBe( + "Press E then T to jump to the title", + ); + expect(plainTextById.nudge).toBe( + "Hold Shift and press an arrow to nudge this event", + ); + expect(plainTextById["target-event"]).toBe( + "Tap Shift to jump to any visible event", + ); + expect(plainTextById["edge-cycle"]).toBe( + "Press Tab to move between start and end", + ); + }); +}); diff --git a/packages/web/src/shortcuts/tips/shortcut-tips.data.ts b/packages/web/src/shortcuts/tips/shortcut-tips.data.ts index 53e0046cea..27b1e32fc7 100644 --- a/packages/web/src/shortcuts/tips/shortcut-tips.data.ts +++ b/packages/web/src/shortcuts/tips/shortcut-tips.data.ts @@ -4,17 +4,46 @@ export type ShortcutTipId = | "target-event" | "edge-cycle"; +export type ShortcutTipPart = string | { key: string }; + export type ShortcutTip = { id: ShortcutTipId; - text: string; + parts: ShortcutTipPart[]; }; +export const getTipPlainText = (tip: ShortcutTip): string => + tip.parts + .map((part) => (typeof part === "string" ? part : part.key)) + .join(""); + /** Small fixed rotation; content mirrors the onboarding tour's advanced lessons. */ export function getShortcutTips(): ShortcutTip[] { return [ - { id: "edit-sequence", text: "Press E then T to jump to the title" }, - { id: "nudge", text: "Hold Shift and press an arrow to nudge this event" }, - { id: "target-event", text: "Tap Shift to jump to any visible event" }, - { id: "edge-cycle", text: "Press Tab to move between start and end" }, + { + id: "edit-sequence", + parts: [ + "Press ", + { key: "E" }, + " then ", + { key: "T" }, + " to jump to the title", + ], + }, + { + id: "nudge", + parts: [ + "Hold ", + { key: "Shift" }, + " and press an arrow to nudge this event", + ], + }, + { + id: "target-event", + parts: ["Tap ", { key: "Shift" }, " to jump to any visible event"], + }, + { + id: "edge-cycle", + parts: ["Press ", { key: "Tab" }, " to move between start and end"], + }, ]; } 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 3e0ab835ed..c89f7c1a45 100644 --- a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx +++ b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx @@ -279,6 +279,43 @@ describe("useDayEventNudgeShortcuts", () => { expect(input.schedule.end).toBe("2026-05-20"); }); + it("converts a focused all-day event to timed with Shift+ArrowDown", async () => { + focusCalendarTarget(ALL_DAY_EVENT_ID, "all-day"); + const { queryClient } = renderEditShortcuts({ + allDayEvents: [allDayEvent], + timedEvents: [], + }); + + pressKey("ArrowDown", 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( + offsetString(dayjs("2026-05-20T09:00:00.000")), + ); + expect(input.schedule.end).toBe( + offsetString(dayjs("2026-05-20T10:00:00.000")), + ); + }); + + it("does not convert an edge-focused all-day event with Shift+ArrowDown", async () => { + focusCalendarTarget(ALL_DAY_EVENT_ID, "all-day"); + const { queryClient } = renderEditShortcuts({ + allDayEvents: [allDayEvent], + timedEvents: [], + }); + pressKey("Tab"); + + pressKey("ArrowDown", shiftKey); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(getEditMutation(queryClient)).toBeUndefined(); + }); + it("does not move all-day events with Shift+ArrowUp", async () => { focusCalendarTarget(ALL_DAY_EVENT_ID, "all-day"); const { queryClient } = renderEditShortcuts({