Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions docs/acceptance/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions e2e/timed/shift-hold-event-hints.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
expectTimedEventVisible,
fillTitleAndSaveEventForm,
getSavedEventsByTitle,
getVisibleDayDates,
openTimedEventFormWithKeyboard,
prepareCalendarPage,
} from "../utils/event-test-utils";
Expand Down Expand Up @@ -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);

Expand Down
46 changes: 6 additions & 40 deletions packages/web/src/common/utils/draft/draft.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);

Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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,
Expand All @@ -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();

Expand All @@ -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,
);
Expand Down
30 changes: 13 additions & 17 deletions packages/web/src/common/utils/draft/draft.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down Expand Up @@ -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());
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ describe("RecurrenceScopeOpportunityHost", () => {
getQuickTimeDay: () => dayjs().startOf("day"),
listVisible: () => [],
timedEvents: [],
visibleDays: [dayjs().startOf("day")],
}),
);

Expand Down
Loading