From e5ecacfbc83787d14f8e9daf68020462edf1bba7 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 4 Sep 2026 20:17:15 -0600 Subject: [PATCH] fix(web): create events on the selected week column On another week, C always seeded the draft on the first visible day, and the timed form has no date field, so the draft was stuck there. Selecting a column with Shift+letter did not help: jump mode swallowed C, and an empty column could not be selected at all. One target-day rule now serves every Week create gesture (C, Shift+C, Shift+Arrow place, typed digits): parked click, then the selected column, then the focused event's day, then today, then the first visible day. Creating spends the selection so chips never linger over the new draft. Any visible column can be selected, with or without events, which also makes the header's Shift+letter chip true for empty days. createTimedDraft and createAlldayDraft take the target day directly, so Week no longer threads isCurrentWeek into the shortcut owner. The bus resubscribes when the event set changes because the create handlers now depend on the focused-event lookup; harmless. Co-Authored-By: Claude Fable 5.1 --- docs/acceptance/shortcuts.md | 20 ++-- e2e/timed/shift-hold-event-hints.spec.ts | 32 ++++++ .../src/common/utils/draft/draft.util.test.ts | 46 ++------- .../web/src/common/utils/draft/draft.util.ts | 30 +++--- .../RecurrenceScopeOpportunityHost.test.tsx | 1 + .../shift-hint/assign-shift-hint-keys.test.ts | 85 ++++++++++++++++ .../shift-hint/assign-shift-hint-keys.ts | 69 ++++++------- ...useShiftHoldEventHints.quick-time.test.tsx | 7 ++ .../useShiftHoldEventHints.test.tsx | 98 +++++++++++++++++-- .../shift-hint/useShiftHoldEventHints.ts | 71 +++++++++----- .../src/shortcuts/tips/selectShortcutHint.ts | 4 +- .../Calendar/DayCalendarGrid.test.tsx | 2 +- .../components/Calendar/DayCalendarGrid.tsx | 16 +-- .../useDayEventNudgeShortcuts.test.tsx | 15 +-- .../shortcuts/useDayEventNudgeShortcuts.ts | 4 + packages/web/src/views/Week/WeekView.tsx | 1 - .../shortcuts/useWeekShortcutOwner.test.tsx | 83 +++++++++++++++- .../hooks/shortcuts/useWeekShortcutOwner.ts | 91 ++++++++--------- 18 files changed, 472 insertions(+), 203 deletions(-) diff --git a/docs/acceptance/shortcuts.md b/docs/acceptance/shortcuts.md index a5ae1b9865..1ac7d24876 100644 --- a/docs/acceptance/shortcuts.md +++ b/docs/acceptance/shortcuts.md @@ -144,18 +144,22 @@ Pressing Cmd+K opens the command palette from any view, including while a text i ### UX -Pressing `C` in Week view opens a new event creation form, equivalent to clicking an empty grid slot. +Pressing `C` in Week view opens a new event creation form on the day the user is looking at: a selected day column (Shift + its day letter, see Scenario 12), else the day of the focused event, else today, else the first visible day. The draft starts at the current hour on that day. The timed form has no date field, so this is how a draft lands on the right day. Creating spends the column selection, so the highlight clears. ### Steps 1. Navigate to `/week`. 2. Ensure no input is focused. -3. Press `C`. +3. Press `C`. Discard the draft. +4. Press `K` to go to next week, then hold Shift and press the letter for an empty day (for example `Shift+R` for Thursday), then press `C`. +5. Discard, focus an event on another day with `U` and the arrows, then press `C`. ### Expected Results -- The event creation form opens. -- The form is equivalent to what would appear after clicking an empty grid slot. +- From idle on the current week, the form opens with the draft on today. +- After selecting a column, the form opens with the draft in that column, and the column highlight clears. +- With an event focused, the draft lands on that event's day. +- `Shift+C` and Shift+Arrow place-create follow the same day. --- @@ -283,7 +287,7 @@ After deleting or moving an event, pressing Cmd+Z (Mac) or Ctrl+Z (Windows/Linux ### UX -Pressing `H` shows event-jump chips. Week view chips use day prefixes (`SU`/`M`/`T`/`W`/`R`/`F`/`SA`) plus a per-day index (`W4`, `SU1`). Day view uses numeric chips (`1`, `2`, …). Pressing a day letter highlights that column and focuses its first event; a following digit focuses that index. From idle, a column is entered with Shift and its day letter (`Shift+W`, `Shift+S` then `U`/`A` for the weekend), which always works: bare `T` stays “go to today”, bare `M` stays “open event menu”, and bare `F` stays “focus latest notice”. Day view keeps bare digits, since `Shift+1` is `!`. `Esc` exits (a second `H` also toggles off). Holding Mod reveals the same day prefixes on week column headers, shown as `⇧W` while jump mode is off. Bare Shift and Shift+Tab do not show jump chips. +Pressing `H` shows event-jump chips. Week view chips use day prefixes (`SU`/`M`/`T`/`W`/`R`/`F`/`SA`) plus a per-day index (`W4`, `SU1`). Day view uses numeric chips (`1`, `2`, …). Pressing a day letter highlights that column and focuses its first event, if it has one; an empty column is still selected, so `C`, `Shift+C`, Shift+Arrow, and typed digits (`1400`) create on it. A following digit focuses that index. From idle, a column is entered with Shift and its day letter (`Shift+W`, `Shift+S` then `U`/`A` for the weekend), which always works: bare `T` stays “go to today”, bare `M` stays “open event menu”, and bare `F` stays “focus latest notice”. Day view keeps bare digits, since `Shift+1` is `!`. `Esc` exits (a second `H` also toggles off). Holding Mod reveals the same day prefixes on week column headers, shown as `⇧W` while jump mode is off. Bare Shift and Shift+Tab do not show jump chips. ### Steps @@ -297,7 +301,7 @@ Pressing `H` shows event-jump chips. Week view chips use day prefixes (`SU`/`M`/ ### Expected Results - Chips appear on events currently visible in the grid when `H` is pressed and stay until Esc. Scrolled-off events keep their jump keys but hide their chips. -- A day letter highlights that column and focuses the first event; digits refine to `Wn`. +- A day letter highlights that column and focuses the first event; digits refine to `Wn`. On a column with no events the highlight still appears, nothing focuses, and `C` or a typed `HHMM` creates there. - Shift + the day letter enters a column from idle, including while an event is focused; `H` remains available to reveal every chip. Weekend columns use `Shift+S` then `A` / `U`. - Bare `T` still goes to today while jump is off. With an event focused, bare `M` still opens the event menu. With a visible notice, bare `F` still focuses that notice. - Arrow keys keep jump mode on so letter-then-arrows works. @@ -461,7 +465,7 @@ If time is limited, run these checks before shipping shortcut-related changes: 2. `J` and `K` navigate days in Day view and weeks in Week view. 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; Undo/Redo rows are present. -5. `C` opens a timed event form and `Shift+C` an all-day event form, in both Day and Week view. +5. `C` opens a timed event form and `Shift+C` an all-day event form, in both Day and Week view. In Week view both land on the selected column, else the focused event's day, else today, else the first visible day. 6. `]` toggles the sidebar in both Week and Day view. 7. Delete removes a focused 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. @@ -471,7 +475,7 @@ If time is limited, run these checks before shipping shortcut-related changes: 12. With no event focused and no particular control focused (document body), any Arrow key focuses the timed event nearest now in the current Day/Week view (in-progress, else next upcoming, else most recently ended; today preferred in Week; all-day only if no timed events). Further arrows then follow the existing rules. `U` still focuses the first DOM-order event. With a focused event and no draft open: in Week view ArrowUp/ArrowDown stay on the same day and ArrowLeft/Right jump to the time-nearest event on the previous/next non-empty day; in Day view all four arrows move chronological focus. 13. Cmd+D / Ctrl+D duplicates a focused event in Day and Week view. 14. With a focused event, `E` then `T` opens the form with the title focused; `E` then `A` / `C` jump to guests / color; bare `E` alone does nothing. -15. Pressing `H` shows event jump chips; a day letter + digit focuses that event; `Shift` + the day letter enters a column without a prior `H`; Shift+Tab does not show chips. +15. Pressing `H` shows event jump chips; a day letter + digit focuses that event; `Shift` + the day letter enters a column without a prior `H`, including an empty column, and `C` then creates there; Shift+Tab does not show chips. 16. Mouse clicks, right-clicks, and double-clicks are inert on calendar views; a blocked click shows the keyboard-only hint. `/life` allows normal clicks. `M` opens the focused event's menu; `F` focuses the newest notice. 17. PageUp / PageDown scroll the timed grid by one viewport in Day and Week view even when an event is focused; they do not fire in a text input. 18. Alt+ArrowUp / Alt+ArrowDown pan the timed grid by one hour in Day and Week view even when an event is focused; they do not fire in a text input. diff --git a/e2e/timed/shift-hold-event-hints.spec.ts b/e2e/timed/shift-hold-event-hints.spec.ts index 4d5af48f83..ec08f20a7d 100644 --- a/e2e/timed/shift-hold-event-hints.spec.ts +++ b/e2e/timed/shift-hold-event-hints.spec.ts @@ -4,6 +4,7 @@ import { expectTimedEventVisible, fillTitleAndSaveEventForm, getSavedEventsByTitle, + getVisibleDayDates, openTimedEventFormWithKeyboard, prepareCalendarPage, } from "../utils/event-test-utils"; @@ -151,6 +152,37 @@ test("Shift and the day letter focuses an event without first pressing h", async await expect(shiftHintOverlay(page)).toHaveCount(1); }); +test("Shift and a day letter then c creates on that column of another week", async ({ + page, +}) => { + await prepareCalendarPage(page); + await blurActive(page); + + // Next window, so today is out of view and a create that ignored the + // selection would fall back to the first visible day. Target the last + // column: it is never that fallback, and it needs no events of its own to + // be selectable. + const thisWindow = await getVisibleDayDates(page); + await page.keyboard.press("k"); + await expect.poll(() => getVisibleDayDates(page)).not.toEqual(thisWindow); + const nextWindow = await getVisibleDayDates(page); + const targetDate = nextWindow[nextWindow.length - 1]!; + const weekday = new Date(`${targetDate}T12:00:00`).getDay(); + const keys = DAY_PREFIX_KEYS[WEEKDAY_PREFIXES[weekday]!]!; + + await page.keyboard.press(`Shift+${keys[0]}`); + if (keys[1]) await page.keyboard.press(keys[1]); + await page.keyboard.press("c"); + + const title = createEventTitle("Column Create"); + await fillTitleAndSaveEventForm(page, title); + + const [saved] = await getSavedEventsByTitle(page, title); + expect(new Date(saved.startDate).getDay()).toBe(weekday); + // Creating spends the selection, so no chips linger over the new event. + await expect(shiftHintOverlay(page)).toHaveCount(0); +}); + test("Shift+Tab does not toggle event jump keys", async ({ page }) => { await prepareCalendarPage(page); diff --git a/packages/web/src/common/utils/draft/draft.util.test.ts b/packages/web/src/common/utils/draft/draft.util.test.ts index b961a4d08a..63826836e1 100644 --- a/packages/web/src/common/utils/draft/draft.util.test.ts +++ b/packages/web/src/common/utils/draft/draft.util.test.ts @@ -45,12 +45,11 @@ describe("shortcut draft creation", () => { draftActions.discard(); }); - it("creates a one-day all-day draft on today when today is inside the visible week", async () => { + it("creates a one-day all-day draft on the given day", async () => { setSystemTime(new Date("2026-05-20T10:07:00.000Z")); await createAlldayDraft( - dayjs("2026-05-18T00:00:00.000Z"), - dayjs("2026-05-24T23:59:59.999Z"), + dayjs("2026-05-20T15:00:00.000Z"), "createShortcut", ); @@ -68,33 +67,10 @@ describe("shortcut draft creation", () => { } }); - it("creates a one-day all-day draft on the visible week anchor when today is outside the visible week", async () => { + it("creates timed drafts at the current hour on the given day", async () => { setSystemTime(new Date("2026-05-20T10:07:00.000Z")); - await createAlldayDraft( - dayjs("2026-06-01T00:00:00.000Z"), - dayjs("2026-06-07T23:59:59.999Z"), - "createShortcut", - ); - - const { gridDraft, status } = useDraftStore.getState(); - - expect(status?.eventType).toBe(Categories_Event.ALLDAY); - expectSameTime( - gridDraft?.values.schedule.start, - "2026-06-01T00:00:00.000Z", - ); - expectSameTime(gridDraft?.values.schedule.end, "2026-06-02T00:00:00.000Z"); - }); - - it("creates timed drafts on the visible week anchor when today is outside the visible week", async () => { - setSystemTime(new Date("2026-05-20T10:07:00.000Z")); - - await createTimedDraft( - false, - dayjs("2026-06-01T00:00:00.000Z"), - "createShortcut", - ); + await createTimedDraft(dayjs("2026-06-01T00:00:00.000Z"), "createShortcut"); const { gridDraft, status } = useDraftStore.getState(); @@ -111,11 +87,7 @@ describe("shortcut draft creation", () => { // send the draft to the all-day row (multi-day timed display). setSystemTime(new Date("2026-05-20T23:22:00.000Z")); - await createTimedDraft( - true, - dayjs("2026-05-18T00:00:00.000Z"), - "createShortcut", - ); + await createTimedDraft(dayjs("2026-05-20T00:00:00.000Z"), "createShortcut"); const { gridDraft, status } = useDraftStore.getState(); @@ -132,7 +104,6 @@ describe("shortcut draft creation", () => { const calendarId = CalendarIdSchema.parse(createObjectIdString()); await createTimedDraft( - true, dayjs("2026-05-20T00:00:00.000Z"), "createShortcut", calendarId, @@ -146,11 +117,7 @@ describe("shortcut draft creation", () => { it("creates a keyboardPlace timed draft with the form closed", async () => { setSystemTime(new Date("2026-05-20T10:07:00.000Z")); - await createTimedDraft( - true, - dayjs("2026-05-20T00:00:00.000Z"), - "keyboardPlace", - ); + await createTimedDraft(dayjs("2026-05-20T00:00:00.000Z"), "keyboardPlace"); const { gridDraft, status } = useDraftStore.getState(); @@ -170,7 +137,6 @@ describe("shortcut draft creation", () => { await createAlldayDraft( dayjs("2026-05-18T00:00:00.000Z"), - dayjs("2026-05-24T23:59:59.999Z"), "createShortcut", calendarId, ); diff --git a/packages/web/src/common/utils/draft/draft.util.ts b/packages/web/src/common/utils/draft/draft.util.ts index cc45618204..68b24ae6f4 100644 --- a/packages/web/src/common/utils/draft/draft.util.ts +++ b/packages/web/src/common/utils/draft/draft.util.ts @@ -23,13 +23,14 @@ import { getEffectiveTimeZone } from "@web/timezone/effective-timezone.store"; // viewport rather than glued flush against it. const VISIBLE_START_MARGIN_MIN = 30; +/** Timed draft at the current hour on `targetDay`, the day the user is + * looking at (selected column, focused event, today, or the view anchor). */ export const createTimedDraft = ( - isCurrentWeek: boolean, - startOfView: Dayjs, + targetDay: Dayjs, activity: "createShortcut" | "keyboardPlace", calendarId: CalendarId | null = null, ) => { - const { startDate, endDate } = getDraftTimes(isCurrentWeek, startOfView); + const { startDate, endDate } = getDraftTimes(targetDay); startTimedDraftAt(startDate, endDate, activity, calendarId); }; @@ -78,21 +79,13 @@ export const timedDraftEnd = (start: Dayjs): Dayjs => { : oneHourEnd; }; +/** One-day all-day draft on `targetDay`; the caller picks the day. */ export const createAlldayDraft = ( - startOfView: Dayjs, - endOfView: Dayjs, + targetDay: Dayjs, activity: "createShortcut", calendarId: CalendarId | null = null, - /** Day a blocked pointer click picked, which wins over the today-first - * default so the draft lands on the day the user actually aimed at. */ - startAt?: Dayjs, ) => { - const today = dayjs().tz(getEffectiveTimeZone()); - const start = - startAt?.startOf("day") ?? - (today.isBetween(startOfView, endOfView, "day", "[]") - ? today.startOf("day") - : startOfView.startOf("day")); + const start = targetDay.startOf("day"); // Same stable identity as timed shortcut drafts so save can reuse it as // CreateEventInput.id and restore focus to the new card. const clientId = EventIdSchema.parse(createObjectIdString()); @@ -109,13 +102,16 @@ export const createAlldayDraft = ( draftActions.startGridDraft({ activity, draft }); }; -export const getDraftTimes = (isCurrentWeek: boolean, startOfWeek: Dayjs) => { +export const getDraftTimes = (targetDay: Dayjs) => { const now = dayjs().tz(getEffectiveTimeZone()); const currentMinute = now.minute(); const nextMinuteInterval = roundToNext(currentMinute, GRID_TIME_STEP); - const fullStart = isCurrentWeek ? now : startOfWeek.hour(now.hour()); - const _start = fullStart.minute(nextMinuteInterval).second(0); + const _start = targetDay + .startOf("day") + .hour(now.hour()) + .minute(nextMinuteInterval) + .second(0); const startDate = _start.format(); const endDate = timedDraftEnd(_start).format(); diff --git a/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.test.tsx b/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.test.tsx index 78d2aa9e03..36d3fd29df 100644 --- a/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.test.tsx +++ b/packages/web/src/events/recurrence/RecurrenceScopeOpportunityHost.test.tsx @@ -111,6 +111,7 @@ describe("RecurrenceScopeOpportunityHost", () => { getQuickTimeDay: () => dayjs().startOf("day"), listVisible: () => [], timedEvents: [], + visibleDays: [dayjs().startOf("day")], }), ); diff --git a/packages/web/src/shortcuts/shift-hint/assign-shift-hint-keys.test.ts b/packages/web/src/shortcuts/shift-hint/assign-shift-hint-keys.test.ts index 8ae8f202f3..8c0e315dfc 100644 --- a/packages/web/src/shortcuts/shift-hint/assign-shift-hint-keys.test.ts +++ b/packages/web/src/shortcuts/shift-hint/assign-shift-hint-keys.test.ts @@ -223,10 +223,22 @@ describe("matchDayJumpKeystroke", () => { })), ]); + // The visible columns of the week above, Sunday 2026-08-02 through Saturday. + const weekDayKeys = { + su: "2026-08-02", + m: "2026-08-03", + t: "2026-08-04", + w: "2026-08-05", + r: "2026-08-06", + f: "2026-08-07", + sa: "2026-08-08", + }; + it("selects a unique day and focuses its first event", () => { expect( matchDayJumpKeystroke({ assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, key: "w", buffer: "", }), @@ -243,6 +255,7 @@ describe("matchDayJumpKeystroke", () => { expect( matchDayJumpKeystroke({ assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, key: "s", buffer: "", }), @@ -255,6 +268,7 @@ describe("matchDayJumpKeystroke", () => { expect( matchDayJumpKeystroke({ assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, key: "u", buffer: "s", }), @@ -269,6 +283,7 @@ describe("matchDayJumpKeystroke", () => { expect( matchDayJumpKeystroke({ assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, key: "a", buffer: "s", }), @@ -285,6 +300,7 @@ describe("matchDayJumpKeystroke", () => { expect( matchDayJumpKeystroke({ assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, key: "4", buffer: "w", }), @@ -300,6 +316,7 @@ describe("matchDayJumpKeystroke", () => { expect( matchDayJumpKeystroke({ assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, key: "1", buffer: "f", }), @@ -313,6 +330,7 @@ describe("matchDayJumpKeystroke", () => { expect( matchDayJumpKeystroke({ assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, key: "0", buffer: "f1", }), @@ -328,6 +346,7 @@ describe("matchDayJumpKeystroke", () => { expect( matchDayJumpKeystroke({ assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, key: "w", buffer: "s", }), @@ -340,6 +359,71 @@ describe("matchDayJumpKeystroke", () => { }); }); + it("selects an empty visible day with nothing to focus", () => { + expect( + matchDayJumpKeystroke({ + assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, + key: "r", + buffer: "", + }), + ).toEqual({ + kind: "selectDay", + dayPrefix: "r", + dayKey: "2026-08-06", + firstEventId: null, + buffer: "r", + }); + }); + + it("narrows weekend to the visible weekend columns when they have no events", () => { + expect( + matchDayJumpKeystroke({ + assignments: [], + dayKeyByPrefix: weekDayKeys, + key: "s", + buffer: "", + }), + ).toEqual({ + kind: "prefix", + buffer: "s", + dayKeys: ["2026-08-02", "2026-08-08"], + }); + expect( + matchDayJumpKeystroke({ + assignments: [], + dayKeyByPrefix: weekDayKeys, + key: "a", + buffer: "s", + }), + ).toEqual({ + kind: "selectDay", + dayPrefix: "sa", + dayKey: "2026-08-08", + firstEventId: null, + buffer: "sa", + }); + }); + + it("does not select a day with no visible column", () => { + expect( + matchDayJumpKeystroke({ + assignments: [], + dayKeyByPrefix: { w: "2026-08-05" }, + key: "t", + buffer: "", + }), + ).toBeNull(); + expect( + matchDayJumpKeystroke({ + assignments: [], + dayKeyByPrefix: { w: "2026-08-05" }, + key: "s", + buffer: "", + }), + ).toBeNull(); + }); + it("filters chips by prefix", () => { expect( filterHintsByPrefix(weekAssignments, "w").map((a) => a.hint), @@ -350,6 +434,7 @@ describe("matchDayJumpKeystroke", () => { expect( matchDayJumpKeystroke({ assignments: weekAssignments, + dayKeyByPrefix: weekDayKeys, key: "2", buffer: "", }), diff --git a/packages/web/src/shortcuts/shift-hint/assign-shift-hint-keys.ts b/packages/web/src/shortcuts/shift-hint/assign-shift-hint-keys.ts index 865ecb9510..96156a61d0 100644 --- a/packages/web/src/shortcuts/shift-hint/assign-shift-hint-keys.ts +++ b/packages/web/src/shortcuts/shift-hint/assign-shift-hint-keys.ts @@ -139,7 +139,8 @@ export type DayJumpMatchResult = kind: "selectDay"; dayPrefix: string; dayKey: string; - firstEventId: string; + /** Null when the column has no events: the day is selected, nothing focuses. */ + firstEventId: string | null; buffer: string; } | { @@ -154,12 +155,16 @@ export type DayJumpMatchResult = const UNIQUE_DAY_LETTERS = new Set(["m", "t", "w", "r", "f"]); +/** Visible column per day prefix (`"r"` -> `"2026-10-15"`), whether or not + * that day has events. Week passes all of its columns; Day passes its one. */ +export type DayKeyByPrefix = Readonly>; + /** * Resolve a keystroke against assignments + buffer. * * Buffer holds the typed prefix so far (`""`, `"s"`, `"su"`, `"w"`, `"w1"`…). - * Unique day letters select the day and focus the first event without requiring - * a digit. Digits append and focus when the buffer uniquely identifies a hint + * Unique day letters select any visible day, focusing its first event when it + * has one. Digits append and focus when the buffer uniquely identifies a hint * (exact match with no longer sibling prefixes). * * A digit on an empty buffer is deliberately unmatched: no day is selected yet, @@ -167,10 +172,12 @@ const UNIQUE_DAY_LETTERS = new Set(["m", "t", "w", "r", "f"]); */ export function matchDayJumpKeystroke({ assignments, + dayKeyByPrefix, key, buffer, }: { assignments: DayJumpAssignment[]; + dayKeyByPrefix: DayKeyByPrefix; key: string; buffer: string; }): DayJumpMatchResult { @@ -187,36 +194,28 @@ export function matchDayJumpKeystroke({ if (!/^[a-z]$/.test(lower)) return null; - // Starting a new day selection (or continuing weekend). - if (!buffer || buffer === "s") { - if (buffer === "" && lower === "s") { - const weekend = filterHintsByPrefix(assignments, "s"); - if (weekend.length === 0) return null; - const dayKeys = uniqueDayKeys(weekend); - return { kind: "prefix", buffer: "s", dayKeys }; - } - - if (buffer === "s" && (lower === "u" || lower === "a")) { - const dayPrefix = lower === "u" ? "su" : "sa"; - return selectDayPrefix(assignments, dayPrefix); - } - - // Abandon an unresolved weekend "s" when another day letter is typed. - if (UNIQUE_DAY_LETTERS.has(lower)) { - return selectDayPrefix(assignments, lower); - } - - return null; + if (buffer === "s" && (lower === "u" || lower === "a")) { + return selectDayPrefix( + assignments, + dayKeyByPrefix, + lower === "u" ? "su" : "sa", + ); } - // Day already selected (or digits in progress): letter starts a new day pick. + // Any other letter starts a new day pick, abandoning an unresolved weekend + // "s" or a day already selected. if (lower === "s") { - const weekend = filterHintsByPrefix(assignments, "s"); - if (weekend.length === 0) return null; - return { kind: "prefix", buffer: "s", dayKeys: uniqueDayKeys(weekend) }; + const dayKeys = [ + ...new Set([ + ...uniqueDayKeys(filterHintsByPrefix(assignments, "s")), + ...["su", "sa"].flatMap((prefix) => dayKeyByPrefix[prefix] ?? []), + ]), + ]; + if (dayKeys.length === 0) return null; + return { kind: "prefix", buffer: "s", dayKeys }; } if (UNIQUE_DAY_LETTERS.has(lower)) { - return selectDayPrefix(assignments, lower); + return selectDayPrefix(assignments, dayKeyByPrefix, lower); } return null; @@ -224,20 +223,22 @@ export function matchDayJumpKeystroke({ function selectDayPrefix( assignments: DayJumpAssignment[], + dayKeyByPrefix: DayKeyByPrefix, dayPrefix: string, ): DayJumpMatchResult { const forDay = assignments.filter( (assignment) => assignment.dayPrefix === dayPrefix, ); - if (forDay.length === 0) return null; - const first = forDay.reduce((best, item) => - item.index < best.index ? item : best, - ); + const first = forDay.length + ? forDay.reduce((best, item) => (item.index < best.index ? item : best)) + : null; + const dayKey = first?.dayKey ?? dayKeyByPrefix[dayPrefix]; + if (!dayKey) return null; return { kind: "selectDay", dayPrefix, - dayKey: first.dayKey, - firstEventId: first.eventId, + dayKey, + firstEventId: first?.eventId ?? null, buffer: dayPrefix, }; } diff --git a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.quick-time.test.tsx b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.quick-time.test.tsx index b0d868ef77..45db8b5de1 100644 --- a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.quick-time.test.tsx +++ b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.quick-time.test.tsx @@ -22,6 +22,10 @@ import { useShiftHoldEventHints } from "@web/shortcuts/shift-hint/useShiftHoldEv import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; const TARGET_DAY = dayjs().startOf("day"); +/** The Sunday-to-Saturday week around TARGET_DAY; every fixture below sits in it. */ +const VISIBLE_DAYS = Array.from({ length: 7 }, (_, index) => + TARGET_DAY.day(0).add(index, "day"), +); const beginSeriesAsk = () => recurrenceScopeOpportunityActions.begin({ @@ -62,6 +66,7 @@ const mountOwner = () => { getQuickTimeDay: () => TARGET_DAY, listVisible: () => [], timedEvents: [], + visibleDays: VISIBLE_DAYS, }), ); @@ -335,6 +340,7 @@ describe("typed-time deferred commit", () => { { eventId: COLUMN_EVENT_ID, eventType: "timed", element: el }, ], timedEvents: [event], + visibleDays: VISIBLE_DAYS, }), ); @@ -433,6 +439,7 @@ describe("typed-time deferred commit", () => { element: fridayElements[index]!, })), timedEvents: events, + visibleDays: VISIBLE_DAYS, }), ); diff --git a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx index d5f3f23dc8..6b130dafb8 100644 --- a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx +++ b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.test.tsx @@ -23,6 +23,10 @@ const EVENT_A = EventIdSchema.parse("aaaaaaaaaaaaaaaaaaaaaaaa"); const EVENT_B = EventIdSchema.parse("bbbbbbbbbbbbbbbbbbbbbbbb"); const EVENT_C = EventIdSchema.parse("cccccccccccccccccccccccc"); const EVENT_D = EventIdSchema.parse("dddddddddddddddddddddddd"); +/** Sunday 2026-08-02 through Saturday 2026-08-08, the week the fixtures use. */ +const WEEK_DAYS = Array.from({ length: 7 }, (_, index) => + dayjs("2026-08-02").add(index, "day"), +); const dispatch = ( type: "keydown" | "keyup", @@ -114,6 +118,7 @@ describe("useShiftHoldEventHints", () => { element: elements[index]!, })), timedEvents: events, + visibleDays: WEEK_DAYS, }), ); @@ -197,10 +202,46 @@ describe("useShiftHoldEventHints", () => { expect(useEventJumpStore.getState().isActive).toBe(true); }); - it("does not claim a day letter with no events that day", () => { + it("selects an empty day from idle without focusing anything", () => { + const { focus } = mountHints([ + timedFixture(EVENT_C, "2026-08-06T13:00:00.000Z"), + ]); + + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + composed: true, + key: "W", + shiftKey: true, + }); + act(() => { + document.dispatchEvent(event); + }); + + expect(event.defaultPrevented).toBe(true); + expect(useEventJumpStore.getState().isActive).toBe(true); + expect(useEventJumpStore.getState().activeDayKeys).toEqual(["2026-08-05"]); + expect(useEventJumpStore.getState().announcement).toBe( + "Wednesday selected", + ); + expect(focus).not.toHaveBeenCalled(); + }); + + it("does not claim a day letter for a column that is not visible", () => { const { focus, result } = mountHints([ timedFixture(EVENT_C, "2026-08-06T13:00:00.000Z"), ]); + cleanup(); + renderHook(() => + useShiftHoldEventHints({ + createAtTime: () => {}, + focus: (target) => focus(target), + getQuickTimeDay: () => dayjs("2026-08-06"), + listVisible: () => [], + timedEvents: [], + visibleDays: [dayjs("2026-08-06")], + }), + ); const event = new KeyboardEvent("keydown", { bubbles: true, @@ -219,6 +260,39 @@ describe("useShiftHoldEventHints", () => { expect(result.current.hints).toEqual([]); }); + it("leaves c and Shift+C unclaimed while a day is selected", () => { + mountHints(); + + act(() => { + pressDayJump("w"); + }); + expect(useEventJumpStore.getState().activeDayKeys).toEqual(["2026-08-05"]); + + for (const shiftKey of [false, true]) { + const down = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: shiftKey ? "C" : "c", + shiftKey, + }); + const up = new KeyboardEvent("keyup", { + bubbles: true, + cancelable: true, + key: shiftKey ? "C" : "c", + shiftKey, + }); + act(() => { + document.dispatchEvent(down); + document.dispatchEvent(up); + }); + expect(down.defaultPrevented).toBe(false); + expect(up.defaultPrevented).toBe(false); + } + // The create owner, not this hook, spends the selection. + expect(useEventJumpStore.getState().isActive).toBe(true); + expect(useEventJumpStore.getState().activeDayKeys).toEqual(["2026-08-05"]); + }); + it("routes a bare digit to typed-time creation while jump is off", () => { const { focus } = mountHints(); @@ -490,6 +564,7 @@ describe("useShiftHoldEventHints", () => { element: elements[index]!, })), timedEvents, + visibleDays: WEEK_DAYS, }), ); @@ -600,6 +675,7 @@ describe("useShiftHoldEventHints", () => { ]; }), timedEvents, + visibleDays: WEEK_DAYS, }), { initialProps: { timedEvents: events } }, ); @@ -621,15 +697,23 @@ describe("useShiftHoldEventHints", () => { expect(useEventJumpStore.getState().pointerHintKey).toBe("W3"); }); - it("publishes the columns a day key can currently land on", () => { - mountHints([ - timedFixture(EVENT_A, "2026-08-05T09:00:00.000Z"), - timedFixture(EVENT_B, "2026-08-08T11:00:00.000Z"), - ]); + it("publishes every visible column as a day key can select empty ones", () => { + cleanup(); + renderHook(() => + useShiftHoldEventHints({ + createAtTime: () => {}, + focus: () => {}, + getQuickTimeDay: () => dayjs("2026-08-05"), + listVisible: () => [], + timedEvents: [], + visibleDays: WEEK_DAYS.slice(3, 6), + }), + ); expect(useEventJumpStore.getState().jumpableDayPrefixes).toEqual([ "w", - "sa", + "r", + "f", ]); }); diff --git a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts index 14bebbc9f6..0006bd004c 100644 --- a/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts +++ b/packages/web/src/shortcuts/shift-hint/useShiftHoldEventHints.ts @@ -32,8 +32,10 @@ import { } from "@web/shortcuts/quick-time/quick-time.util"; import { assignDayJumpKeys, + DAY_JUMP_PREFIX_BY_WEEKDAY, type DayJumpAssignment, type DayJumpMatchResult, + type DayJumpWeekday, DIGIT_AMBIGUOUS_COMMIT_MS, dayJumpPrefixesForWeekdays, dayNameForPrefix, @@ -165,12 +167,15 @@ const buildDayJumpAssignments = ( /** * Press `h` to show day-prefix jump labels, or Shift+ to enter * jump mode straight onto that column (`Shift+W`, `Shift+S` then `u`/`a`). - * Shift is what makes the day columns always available: bare `t`, `f`, and - * `m` keep their own commands. Once jump mode is on, the labels are typed - * bare (`w`, `w1`…) and win over global shortcuts. Esc exits, a second `h` - * toggles off. `s` is only the Sunday/Saturday prefix. `e` is not a day - * prefix: jump yields to an armed (or about-to-arm) edit sequence so `e` - * then `t` edits the title instead of selecting Tuesday. + * Any visible column can be selected, empty ones included, so `c`, `Shift+C`, + * and typed digits have a day to land on; a column with events also focuses + * its first one. Shift is what makes the day columns always available: bare + * `t`, `f`, and `m` keep their own commands. Once jump mode is on, the labels + * are typed bare (`w`, `w1`…) and win over global shortcuts. Esc exits, a + * second `h` toggles off. `s` is only the Sunday/Saturday prefix. `e` is not + * a day prefix: jump yields to an armed (or about-to-arm) edit sequence so + * `e` then `t` edits the title instead of selecting Tuesday. `c` is left + * unclaimed too, so the create shortcuts can consume the selected column. * * Day view uses the same scheme on the one date it shows, so a bare digit is * never an event label on an empty buffer in either view. This listener owns @@ -184,6 +189,7 @@ export function useShiftHoldEventHints({ getQuickTimeDay, listVisible, timedEvents, + visibleDays, }: { allDayEvents?: GridEvent[]; createAtTime: (start: Dayjs) => void; @@ -191,6 +197,8 @@ export function useShiftHoldEventHints({ getQuickTimeDay: () => Dayjs; listVisible: () => ShiftHintFocusTarget[]; timedEvents: GridEvent[]; + /** Columns on screen, so a day letter can select one with no events. */ + visibleDays: Dayjs[]; }): EventJumpHintsResult { const [hints, setHints] = useState([]); const isActive = useEventJumpStore((state) => state.isActive); @@ -214,6 +222,7 @@ export function useShiftHoldEventHints({ const listVisibleRef = useRef(listVisible); const allDayEventsRef = useRef(allDayEvents); const timedEventsRef = useRef(timedEvents); + const visibleDaysRef = useRef(visibleDays); const publishedPrefixesRef = useRef([]); createAtTimeRef.current = createAtTime; @@ -222,6 +231,7 @@ export function useShiftHoldEventHints({ listVisibleRef.current = listVisible; allDayEventsRef.current = allDayEvents; timedEventsRef.current = timedEvents; + visibleDaysRef.current = visibleDays; isActiveRef.current = isActive; useEffect(() => { @@ -339,6 +349,14 @@ export function useShiftHoldEventHints({ return true; }; + const dayKeyByPrefix = () => + Object.fromEntries( + visibleDaysRef.current.map((day) => [ + DAY_JUMP_PREFIX_BY_WEEKDAY[day.day() as DayJumpWeekday], + day.format(YEAR_MONTH_DAY_FORMAT), + ]), + ); + const clearHints = () => { clearAmbiguousCommitTimer(); assignmentsRef.current = []; @@ -490,7 +508,7 @@ export function useShiftHoldEventHints({ item.eventId === pointerHintEventId && item.dayKey === match.dayKey, ); - if (!keepClickedEvent) { + if (!keepClickedEvent && match.firstEventId) { focusEvent(match.firstEventId); } return; @@ -552,11 +570,9 @@ export function useShiftHoldEventHints({ const key = normalizedKeyboardKey(event); if (!isShiftedSingleChar(event)) return; - const assignments = rebuildAssignments(); - if (assignments.length === 0) return; - const match = matchDayJumpKeystroke({ - assignments, + assignments: rebuildAssignments(), + dayKeyByPrefix: dayKeyByPrefix(), key, buffer: "", }); @@ -652,6 +668,7 @@ export function useShiftHoldEventHints({ // Swallow j/k and other unmatched printable shortcuts while jump is on. const match = matchDayJumpKeystroke({ assignments: assignmentsRef.current, + dayKeyByPrefix: dayKeyByPrefix(), key, buffer: bufferRef.current, }); @@ -668,8 +685,13 @@ export function useShiftHoldEventHints({ stripDigitBuffer(); // `e` is not a day prefix. Leave it unclaimed so a later-registered // edit-sequence listener can still arm (`e` then `t` on a focused - // event). Other unmatched letters stay swallowed so j/k/c cannot fire. - if (key === KEYMAP.editTitle.sequence.leader) { + // event). `c` / Shift+C pass too: the create owners read the selected + // column on keyup and then turn jump off. Other unmatched letters + // stay swallowed so j/k cannot fire. + if ( + key === KEYMAP.editTitle.sequence.leader || + key === KEYMAP.createEvent.hotkey.toLowerCase() + ) { return; } event.preventDefault(); @@ -805,20 +827,21 @@ export function useShiftHoldEventHints({ setHints(toActiveHints(source, visibleById)); }, [eventIdsKey, isActive]); - // Publish the day columns that have a live jump key, so the sidebar tip only - // teaches a keystroke that would actually land somewhere. Derived from the - // event props rather than the DOM registry so it is current before any - // keypress. Events without a start date would otherwise advertise the - // FALLBACK_SCHEDULE's phantom Sunday. - // biome-ignore lint/correctness/useExhaustiveDependencies: eventIdsKey re-runs this when the event set changes; the contents come from refs + // Publish the day columns a letter can select, so the sidebar tip only + // teaches a keystroke that lands somewhere. Every visible column qualifies: + // an empty one still takes `c` and typed times. + const visibleDaysKey = visibleDays + .map((day) => day.format(YEAR_MONTH_DAY_FORMAT)) + .join(","); + + // biome-ignore lint/correctness/useExhaustiveDependencies: visibleDaysKey re-runs this when the columns change; the contents come from refs useEffect(() => { - const weekdays = [...allDayEventsRef.current, ...timedEventsRef.current] - .filter((event) => Boolean(event.startDate)) - .map((event) => scheduleMeta(event).weekday); - const next = dayJumpPrefixesForWeekdays(weekdays); + const next = dayJumpPrefixesForWeekdays( + visibleDaysRef.current.map((day) => day.day()), + ); publishedPrefixesRef.current = next; eventJumpActions.setJumpableDayPrefixes(next); - }, [eventIdsKey]); + }, [visibleDaysKey]); // Only clear what this grid published: switching Day -> Week can mount the // new grid before the old one unmounts, and a blind clear there would blank diff --git a/packages/web/src/shortcuts/tips/selectShortcutHint.ts b/packages/web/src/shortcuts/tips/selectShortcutHint.ts index 12b09f866a..958b3c9f20 100644 --- a/packages/web/src/shortcuts/tips/selectShortcutHint.ts +++ b/packages/web/src/shortcuts/tips/selectShortcutHint.ts @@ -37,8 +37,8 @@ const FOCUSED_POOL = [ /** * The day column to teach next: the first jumpable one at or after tomorrow, - * wrapping through the week. Days with no events have no jump key, so teaching - * them would advertise a dead keystroke. + * wrapping through the week. The grid publishes its visible columns, so a + * day off screen is never taught as a keystroke. */ function pickWeekDayPrefix(ctx: ShortcutHintContext): DayJumpPrefix | null { const jumpable = new Set(ctx.jumpableDayPrefixes ?? []); diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx index 49dc2a6d35..0ee9f8f70b 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.test.tsx @@ -511,7 +511,7 @@ describe("DayCalendarGrid", () => { }); it("opens the form when Enter is pressed on a form-closed keyboardPlace draft", async () => { - createTimedDraft(false, dayjs("2026-05-20T00:00:00.000"), "keyboardPlace"); + createTimedDraft(dayjs("2026-05-20T00:00:00.000"), "keyboardPlace"); const draft = getGridDraft(); expect(draft).not.toBeNull(); if (!draft) return; diff --git a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx index 77fc330ea8..dc7b8d56d4 100644 --- a/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx +++ b/packages/web/src/views/Day/components/Calendar/DayCalendarGrid.tsx @@ -278,13 +278,7 @@ export function DayCalendarGrid() { : dateInView; openShortcutDraft(() => - createAlldayDraft( - start, - start, - "createShortcut", - resolveShortcutCalendarId(), - start, - ), + createAlldayDraft(start, "createShortcut", resolveShortcutCalendarId()), ); eventJumpActions.setPointerDraftIntent(null); }, [dateInView, openShortcutDraft, resolveShortcutCalendarId]); @@ -293,13 +287,12 @@ export function DayCalendarGrid() { () => openShortcutDraft(() => createTimedDraft( - dateInView.isSame(today, "day"), dateInView, "createShortcut", resolveShortcutCalendarId(), ), ), - [dateInView, openShortcutDraft, resolveShortcutCalendarId, today], + [dateInView, openShortcutDraft, resolveShortcutCalendarId], ); // Form stays closed so Shift+Arrow can keep repositioning; Enter opens it. @@ -309,14 +302,13 @@ export function DayCalendarGrid() { openShortcutDraft( () => createTimedDraft( - dateInView.isSame(today, "day"), dateInView, "keyboardPlace", resolveShortcutCalendarId(), ), false, ), - [dateInView, openShortcutDraft, resolveShortcutCalendarId, today], + [dateInView, openShortcutDraft, resolveShortcutCalendarId], ); // Typed-time create lands on the day being viewed, form closed and card // focused, matching placeTimedDraftFromShortcut above. @@ -336,6 +328,7 @@ export function DayCalendarGrid() { ); const getQuickTimeDay = useCallback(() => dateInView, [dateInView]); + const visibleDays = useMemo(() => [dateInView], [dateInView]); const { getEditSequenceAnchor, shiftHints } = useDayEventNudgeShortcuts({ allDayEvents: displayedAllDayEvents, @@ -344,6 +337,7 @@ export function DayCalendarGrid() { navigateToDate, placeTimedDraft: placeTimedDraftFromShortcut, timedEvents: displayedTimedEvents, + visibleDays, }); // The placeholder sits in the column the draft would land in - the calendar 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 787c1749ac..fa077d723f 100644 --- a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx +++ b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.test.tsx @@ -208,6 +208,7 @@ const renderEditShortcuts = ({ navigateToDate, placeTimedDraft, timedEvents, + visibleDays: [dayjs("2026-08-05")], }), { events: contracts, @@ -385,7 +386,7 @@ describe("useDayEventNudgeShortcuts", () => { it("places a keyboardPlace timed draft when Shift+Arrow is pressed with no focus", () => { const placeTimedDraft = mock(() => { - createTimedDraft(true, dayjs("2026-05-20T00:00:00.000"), "keyboardPlace"); + createTimedDraft(dayjs("2026-05-20T00:00:00.000"), "keyboardPlace"); }); renderEditShortcuts({ placeTimedDraft }); @@ -399,11 +400,7 @@ describe("useDayEventNudgeShortcuts", () => { it("repositions a keyboardPlace draft with a later Shift+Arrow", () => { const placeTimedDraft = mock(() => { - createTimedDraft( - false, - dayjs("2026-05-20T00:00:00.000"), - "keyboardPlace", - ); + createTimedDraft(dayjs("2026-05-20T00:00:00.000"), "keyboardPlace"); }); renderEditShortcuts({ placeTimedDraft }); @@ -444,11 +441,7 @@ describe("useDayEventNudgeShortcuts", () => { it("discards a repositioned keyboardPlace draft on Escape", () => { const placeTimedDraft = mock(() => { - createTimedDraft( - false, - dayjs("2026-05-20T00:00:00.000"), - "keyboardPlace", - ); + createTimedDraft(dayjs("2026-05-20T00:00:00.000"), "keyboardPlace"); }); renderEditShortcuts({ placeTimedDraft }); diff --git a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.ts b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.ts index 5aa7821ab6..c5d80605f5 100644 --- a/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.ts +++ b/packages/web/src/views/Day/hooks/shortcuts/useDayEventNudgeShortcuts.ts @@ -26,6 +26,7 @@ export function useDayEventNudgeShortcuts({ navigateToDate, placeTimedDraft, timedEvents, + visibleDays, }: { allDayEvents?: GridEvent[]; /** Quick-time create: place a draft at a typed start time. */ @@ -38,6 +39,8 @@ export function useDayEventNudgeShortcuts({ /** Shift+Arrow place-create when nothing is focused and no draft can move. */ placeTimedDraft?: () => void; timedEvents: GridEvent[]; + /** The one column on screen, so its day letter selects it even when empty. */ + visibleDays: Dayjs[]; }): { getEditSequenceAnchor: () => HTMLElement | null; shiftHints: ActiveShiftHint[]; @@ -99,6 +102,7 @@ export function useDayEventNudgeShortcuts({ getQuickTimeDay, listVisible: targeting.listNavigable, timedEvents, + visibleDays, }); return { getEditSequenceAnchor, shiftHints }; diff --git a/packages/web/src/views/Week/WeekView.tsx b/packages/web/src/views/Week/WeekView.tsx index 24a051f6b6..a924a5f00c 100644 --- a/packages/web/src/views/Week/WeekView.tsx +++ b/packages/web/src/views/Week/WeekView.tsx @@ -84,7 +84,6 @@ export const WeekView = () => { const util = weekProps.util; const shortcutProps = { - isCurrentWeek, queryEndOfView: weekProps.query.endOfView, queryStartOfView: weekProps.query.startOfView, startOfView: weekProps.component.startOfView, diff --git a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcutOwner.test.tsx b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcutOwner.test.tsx index 7a29677c31..c98d3987fc 100644 --- a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcutOwner.test.tsx +++ b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcutOwner.test.tsx @@ -37,7 +37,10 @@ import { initialEdgeFocusState, useEdgeFocusStore, } from "@web/grid/shortcuts/edge-focus.store"; -import { eventJumpActions } from "@web/shortcuts/shift-hint/event-jump.store"; +import { + eventJumpActions, + useEventJumpStore, +} from "@web/shortcuts/shift-hint/event-jump.store"; import { getWeekInteractionTargetAttributes, weekEventRegistry, @@ -322,7 +325,6 @@ const renderShortcuts = (options?: { () => useWeekShortcutOwner({ endOfView: dayjs("2026-05-24T00:00:00.000"), - isCurrentWeek: true, queryEndOfView: dayjs("2026-05-24T23:59:59.999"), queryStartOfView: dayjs("2026-05-18T00:00:00.000"), scrollUtil: { scrollToNow: mock() } as never, @@ -1529,6 +1531,83 @@ describe("useWeekShortcutOwner create shortcuts", () => { }); }); +// Thursday 2026-05-21 has no fixture event, so these also prove an empty +// column can be selected. Today (Wednesday) is in view, so a create that +// ignored the selection would land on 2026-05-20 instead. +describe("useWeekShortcutOwner create shortcuts honor the selected column", () => { + const draftDay = () => + dayjs(useDraftStore.getState().gridDraft?.values.schedule.start).format( + "YYYY-MM-DD", + ); + + beforeEach(() => { + setSystemTime(new Date("2026-05-20T10:07:00.000Z")); + }); + + it("creates a timed draft on the selected day with C and spends the selection", async () => { + renderShortcuts(); + + act(() => { + pressKey("R", shiftKey); + }); + expect(useEventJumpStore.getState().activeDayKeys).toEqual(["2026-05-21"]); + + pressKey("C"); + + await waitFor(() => { + expect(useDraftStore.getState().status?.activity).toBe("createShortcut"); + expect(draftDay()).toBe("2026-05-21"); + }); + expect(useEventJumpStore.getState().isActive).toBe(false); + expect(useEventJumpStore.getState().activeDayKeys).toEqual([]); + }); + + it("creates an all-day draft on the selected day with Shift+C", async () => { + renderShortcuts(); + + act(() => { + pressKey("R", shiftKey); + }); + pressKey("C", shiftKey); + + await waitFor(() => { + const { gridDraft, status } = useDraftStore.getState(); + expect(status?.activity).toBe("createShortcut"); + expect(gridDraft?.values.schedule.kind).toBe("allDay"); + expect(draftDay()).toBe("2026-05-21"); + }); + expect(useEventJumpStore.getState().isActive).toBe(false); + }); + + it("places a Shift+Arrow draft on the selected day", async () => { + renderShortcuts(); + + act(() => { + pressKey("R", shiftKey); + }); + pressKey("ArrowDown", shiftKey); + + await waitFor(() => { + const { status } = useDraftStore.getState(); + expect(status?.activity).toBe("keyboardPlace"); + expect(status?.isFormOpen).toBe(false); + expect(draftDay()).toBe("2026-05-21"); + }); + expect(useEventJumpStore.getState().isActive).toBe(false); + }); + + it("creates on today from idle without announcing a jump exit", async () => { + renderShortcuts(); + + pressKey("C"); + + await waitFor(() => { + expect(draftDay()).toBe("2026-05-20"); + }); + expect(useEventJumpStore.getState().announcement).toBe(""); + }); +}); + // The command palette's "Create event"/"Create all-day event" rows emit // these same view commands (event.cmd.constants.ts) instead of calling Week // code directly - this is what lets one shared list serve every view. diff --git a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcutOwner.ts b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcutOwner.ts index d27c250f2a..044a2ad7e8 100644 --- a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcutOwner.ts +++ b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcutOwner.ts @@ -28,6 +28,7 @@ import { } from "@web/shortcuts/quick-time/quick-time.util"; import { eventJumpActions, + isEventJumpActive, useEventJumpStore, } from "@web/shortcuts/shift-hint/event-jump.store"; import { @@ -41,7 +42,6 @@ import { type WeekProps } from "@web/views/Week/hooks/useWeek"; import { weekEventTargeting } from "@web/views/Week/interaction/registry/week-event.registry"; export interface ShortcutProps { - isCurrentWeek: boolean; queryEndOfView: Dayjs; queryStartOfView: Dayjs; startOfView: Dayjs; @@ -56,7 +56,6 @@ export interface ShortcutProps { * then thin key registration via `useCalendarViewShortcuts`. */ export const useWeekShortcutOwner = ({ - isCurrentWeek, queryEndOfView, queryStartOfView, startOfView, @@ -114,36 +113,64 @@ export const useWeekShortcutOwner = ({ // null calendarId while calendars are still loading. const canSeedDraft = !isCalendarsPending || Boolean(defaultTargetCalendarId); - const createAllDayDraftEvent = useCallback(() => { - if (!canSeedDraft) return; + // The day every create gesture lands on: a parked click, then a single + // jump-selected column, then the focused event's day, then today, then the + // first visible day. Typed times, C, Shift+C, and Shift+Arrow place-create + // all share it so "create an event" means the day the user is looking at. + const getTargetDay = useCallback(() => { + const now = dayjs().tz(getEffectiveTimeZone()); + const { pointerDraftDateKey, activeDayKeys } = useEventJumpStore.getState(); + const focusedColumn = quickTimeFocusedColumnDay( + pointerDraftDateKey, + activeDayKeys, + ); + const focusedEvent = + weekEventTargeting.getFocusedNavigableGridEventTarget() ?? + weekEventTargeting.getFocusedGridEventTarget(); + return quickTimeTargetDay( + startOfView, + endOfView, + now, + focusedColumn ?? + quickTimeDayFromEventStart( + focusedEvent + ? [...allDayEvents, ...timedEvents].find( + (event) => event._id === focusedEvent.eventId, + )?.startDate + : undefined, + ), + ); + }, [allDayEvents, endOfView, startOfView, timedEvents]); - // A blocked click on the all-day row aimed at a specific day; honor it - // over the today-first default, then spend the intent. - const pointerDateKey = useEventJumpStore.getState().pointerDraftDateKey; + // Spend the column selection once a create has used it, so jump chips do + // not linger over the new draft. Guarded: turning jump off also announces + // it, and C from idle should stay silent. + const consumeTargetDay = useCallback(() => { + const day = getTargetDay(); + eventJumpActions.setPointerDraftIntent(null); + if (isEventJumpActive()) eventJumpActions.setActive(false); + return day; + }, [getTargetDay]); + const createAllDayDraftEvent = useCallback(() => { + if (!canSeedDraft) return; void createAlldayDraft( - startOfView, - endOfView, + consumeTargetDay(), "createShortcut", defaultTargetCalendarId, - pointerDateKey - ? dayjs(pointerDateKey).tz(getEffectiveTimeZone(), true) - : undefined, ); - eventJumpActions.setPointerDraftIntent(null); - }, [canSeedDraft, defaultTargetCalendarId, endOfView, startOfView]); + }, [canSeedDraft, consumeTargetDay, defaultTargetCalendarId]); const seedTimedDraft = useCallback( (activity: "createShortcut" | "keyboardPlace") => { if (!canSeedDraft) return; void createTimedDraft( - isCurrentWeek, - startOfView, + consumeTargetDay(), activity, defaultTargetCalendarId, ); }, - [canSeedDraft, defaultTargetCalendarId, isCurrentWeek, startOfView], + [canSeedDraft, consumeTargetDay, defaultTargetCalendarId], ); const createTimedDraftEvent = useCallback( @@ -271,33 +298,6 @@ export const useWeekShortcutOwner = ({ // Typed-time create. Same guards and same form-closed/card-focused landing // as Shift+Arrow place-create, so the two gestures produce the same draft. - // A focused column (parked click, jump-selected day, or focused event) - // wins over today so 1230 lands where the user is looking. - const getQuickTimeDay = useCallback(() => { - const now = dayjs().tz(getEffectiveTimeZone()); - const { pointerDraftDateKey, activeDayKeys } = useEventJumpStore.getState(); - const focusedColumn = quickTimeFocusedColumnDay( - pointerDraftDateKey, - activeDayKeys, - ); - const focusedEvent = - weekEventTargeting.getFocusedNavigableGridEventTarget() ?? - weekEventTargeting.getFocusedGridEventTarget(); - return quickTimeTargetDay( - startOfView, - endOfView, - now, - focusedColumn ?? - quickTimeDayFromEventStart( - focusedEvent - ? [...allDayEvents, ...timedEvents].find( - (event) => event._id === focusedEvent.eventId, - )?.startDate - : undefined, - ), - ); - }, [allDayEvents, endOfView, startOfView, timedEvents]); - const createDraftAtTime = useCallback( (start: Dayjs) => { if (!canSeedDraft) return; @@ -317,9 +317,10 @@ export const useWeekShortcutOwner = ({ allDayEvents, createAtTime: createDraftAtTime, focus: targeting.focus, - getQuickTimeDay, + getQuickTimeDay: getTargetDay, listVisible: targeting.listNavigable, timedEvents, + visibleDays: weekDays, }); return { getEditSequenceAnchor, shiftHints };