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
26 changes: 15 additions & 11 deletions docs/acceptance/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ Helpful notes:
| `T` | Day view | Go to today |
| `I` | Day view | Focus sidebar |
| `U` | Day view | Focus first calendar event |
| `Shift` | Day view | Toggle event jump keys |
| `S` | Day view | Toggle event jump keys |
| `H` | Day view | Toggle Hardcore Mode |
| `C` | Day view | Create timed event |
| `A` | Day view | Create all-day event |
| `Delete` | Day view | Delete focused event |
Expand All @@ -78,7 +79,8 @@ Helpful notes:
| `A` | Week view | Create all-day event |
| `I` | Week view | Focus sidebar |
| `U` | Week view | Focus first calendar event |
| `Shift` | Week view | Toggle event jump keys |
| `S` | Week view | Toggle event jump keys |
| `H` | Week view | Toggle Hardcore Mode |
| `Delete` | Week view | Delete focused event |
| `ArrowUp` / `ArrowDown` | Week view | Focus previous/next event |
| `Arrow keys` | Week view | Move open draft event |
Expand Down Expand Up @@ -327,27 +329,29 @@ After deleting or moving an event, pressing Cmd+Z (Mac) or Ctrl+Z (Windows/Linux

---

## Scenario 12: Tap Shift To Jump Focus To An Event By Day Prefix
## Scenario 12: Tap S To Jump Focus To An Event By Day Prefix

### UX

Pressing `Shift` shows event-jump chips immediately; releasing a quick tap keeps the mode on. 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. `Esc` or another Shift tap exits. Long holds and quick chords such as Shift+J or Shift+Arrow cancel the press so jump mode does not stay on. A following Shift-Shift still enters keyboard-only without leaving jump chips up.
Pressing `S` 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. `Esc` exits (in day view a second `S` also toggles off). Bare Shift and Shift+Tab do not show jump chips. Press `H` to toggle Hardcore Mode independently.

### Steps

1. Navigate to `/week` with timed events on at least two different days.
2. Press `Shift` once (do not hold for a chord); chips should appear on key down.
2. Press `S` once; chips should appear.
3. Press the day letter on a chip (for example `W` for Wednesday), then optionally a digit (`2`) or use arrow keys.
4. Press `Esc` (or tap `Shift` again) to exit.
5. Repeat with a fast Shift+J chord and confirm jump mode does not activate.
4. Press `Esc` to exit.
5. Press Shift alone or Shift+Tab and confirm jump mode does not activate.
6. Press `H` and confirm Hardcore Mode enters.

### Expected Results

- Chips appear on events as soon as Shift is pressed and stay after a quick release until Esc / another Shift tap.
- Chips appear on events when `S` is pressed and stay until Esc.
- A day letter highlights that column and focuses the first event; digits refine to `Wn`.
- Arrow keys keep jump mode on so letter-then-arrows works.
- Fast Shift+J / Shift+Arrow do not toggle jump mode.
- While a modal holds the app lock, or focus is in an editable field, Shift does not toggle jump mode.
- Shift alone / Shift+Tab / Shift+J do not toggle jump mode.
- While a modal holds the app lock, or focus is in an editable field, `S` does not toggle jump mode.
- `H` toggles Hardcore Mode; Esc exits it.

---

Expand Down Expand Up @@ -391,4 +395,4 @@ If time is limited, run these checks before shipping shortcut-related changes:
12. With a focused event and no draft open, ArrowUp/ArrowDown move focus to the previous/next event chronologically.
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; bare `E` alone does nothing.
15. Pressing Shift shows event jump chips immediately; a day letter + digit focuses that event; fast Shift+J does not leave the mode on.
15. Pressing `S` shows event jump chips; a day letter + digit focuses that event; Shift+Tab does not show chips. `H` toggles Hardcore Mode.
12 changes: 3 additions & 9 deletions e2e/onboarding/interactive-tour.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => {
await expect(card).toContainText("Jump to Dentist");

// Act 2: targetEvent, move, resizeEdge, placeDraft, undo. The exact
// Shift-hold jump key is covered by e2e/timed/shift-hold-event-hints.spec.ts;
// event-jump key is covered by e2e/timed/shift-hold-event-hints.spec.ts;
// here we drive the mission's actual completion signal (Dentist focused),
// same as a jump would leave it.
const dentistButton = page
Expand All @@ -69,17 +69,11 @@ test("Start Now runs the interactive tour happy path", async ({ page }) => {
await expect(card).toContainText("Graduate to Hardcore Mode");

// Act 3: hardcore graduation, the tour's finale.
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
await page.keyboard.press("h");
await expect(card).toHaveCount(0);

// Leave Hardcore Mode so it doesn't affect other assertions/reload below.
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
await page.keyboard.press("h");

await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.locator("[data-onboarding-tour]")).toHaveCount(0);
Expand Down
22 changes: 6 additions & 16 deletions e2e/timed/keyboard-only-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ type CalendarPage = Parameters<typeof prepareCalendarPage>[0];
const keyboardOnlyIndicator = (page: CalendarPage) =>
page.locator("[data-keyboard-only-indicator]");

const tapShift = async (page: CalendarPage) => {
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
};

const createTimedEventOnGrid = async (page: CalendarPage, label: string) => {
const title = createEventTitle(label);
const { x, y } = await getMainGridPoint(page, {
Expand All @@ -29,7 +24,7 @@ const createTimedEventOnGrid = async (page: CalendarPage, label: string) => {
return page.locator("#mainGrid").getByRole("button", { name: title });
};

test("SHIFT-SHIFT enters keyboard-only mode; clicks are inert until Escape", async ({
test("h enters keyboard-only mode; clicks are inert until Escape", async ({
page,
}) => {
await prepareCalendarPage(page);
Expand All @@ -38,8 +33,7 @@ test("SHIFT-SHIFT enters keyboard-only mode; clicks are inert until Escape", asy
"Keyboard Only Target",
);

await tapShift(page);
await tapShift(page);
await page.keyboard.press("h");

await expect(keyboardOnlyIndicator(page)).toContainText("Hardcore Mode");
await expect(keyboardOnlyIndicator(page)).toContainText("Esc");
Expand All @@ -58,28 +52,24 @@ test("SHIFT-SHIFT enters keyboard-only mode; clicks are inert until Escape", asy
await expect(page.getByLabel("Title")).toBeVisible();
});

test("SHIFT-SHIFT exits keyboard-only mode and restores clicks", async ({
page,
}) => {
test("h exits keyboard-only mode and restores clicks", async ({ page }) => {
await prepareCalendarPage(page);
const eventButton = await createTimedEventOnGrid(
page,
"Keyboard Only Toggle Exit",
);

await tapShift(page);
await tapShift(page);
await page.keyboard.press("h");
await expect(keyboardOnlyIndicator(page)).toBeVisible();

await tapShift(page);
await tapShift(page);
await page.keyboard.press("h");
await expect(keyboardOnlyIndicator(page)).toHaveCount(0);

await eventButton.click();
await expect(page.getByLabel("Title")).toBeVisible();
});

test("hold Shift does not enter keyboard-only or event jump", async ({
test("Shift alone does not enter keyboard-only or event jump", async ({
page,
}) => {
await prepareCalendarPage(page);
Expand Down
24 changes: 16 additions & 8 deletions e2e/timed/shift-hold-event-hints.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,7 @@ const createTimedEventAt = async (
const shiftHintOverlay = (page: CalendarPage) =>
page.locator("[data-shift-event-hints]");

const tapShift = async (page: CalendarPage) => {
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");
};

test("tap Shift shows day-prefix jump keys and focuses the assigned event", async ({
test("tap s shows day-prefix jump keys and focuses the assigned event", async ({
page,
}) => {
await prepareCalendarPage(page);
Expand All @@ -61,7 +56,7 @@ test("tap Shift shows day-prefix jump keys and focuses the assigned event", asyn
document.activeElement.blur();
}
});
await tapShift(page);
await page.keyboard.press("s");

const overlay = shiftHintOverlay(page);
await expect(overlay.locator(":scope > span")).toHaveCount(3);
Expand Down Expand Up @@ -93,12 +88,25 @@ test("tap Shift shows day-prefix jump keys and focuses the assigned event", asyn
await expect(shiftHintOverlay(page)).toHaveCount(0);
});

test("fast Shift+J does not toggle event jump keys", async ({ page }) => {
test("Shift+Tab does not toggle event jump keys", async ({ page }) => {
await prepareCalendarPage(page);

const title = createEventTitle("Chord Quiet");
await createTimedEventAt(page, title, { xRatio: 0.42, yRatio: 0.35 });

await page.keyboard.down("Shift");
await page.keyboard.press("Tab");
await page.keyboard.up("Shift");

await expect(shiftHintOverlay(page)).toHaveCount(0);
});

test("fast Shift+J does not toggle event jump keys", async ({ page }) => {
await prepareCalendarPage(page);

const title = createEventTitle("Chord Quiet J");
await createTimedEventAt(page, title, { xRatio: 0.42, yRatio: 0.35 });

await page.keyboard.down("Shift");
await page.keyboard.down("j");
await page.waitForTimeout(250);
Expand Down
69 changes: 69 additions & 0 deletions packages/web/src/common/utils/datetime/web.date.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import {
getCalendarHeadingLabel,
getColorsByHour,
getHourLabels,
getTimeOptionByValue,
getTimesLabel,
getWeekRangeLabel,
mapToBackend,
parseUserTime,
toUTCOffset,
} from "@web/common/utils/datetime/web.date.util";
import { getFormDates } from "@web/views/Forms/EventForm/DateControlsSection/DateTimeSection/form.datetime.util";
import {
afterAll,
beforeAll,
Expand Down Expand Up @@ -514,3 +517,69 @@ describe("parseUserTime", () => {
expect(result?.label).toBe("10 AM");
});
});

describe("mapToBackend timed overnight", () => {
it("keeps an overnight end on the next calendar day", () => {
const start = dayjs("2026-08-11T23:30:00");
const end = start.add(1, "hour");
const form = getFormDates(start.format(), end.format());

const schedule = mapToBackend({
startDate: form.startDate,
endDate: form.endDate,
startTime: form.startTime,
endTime: form.endTime,
isAllDay: false,
});

expect(schedule.kind).toBe("timed");
if (schedule.kind !== "timed") return;

expect(dayjs(schedule.end).isAfter(dayjs(schedule.start))).toBe(true);
expect(dayjs(schedule.start).format("YYYY-MM-DD HH:mm")).toBe(
"2026-08-11 23:30",
);
expect(dayjs(schedule.end).format("YYYY-MM-DD HH:mm")).toBe(
"2026-08-12 00:30",
);
});

it("still stamps same-day timed ends onto the start calendar day", () => {
const start = dayjs("2026-08-11T14:00:00");
const end = start.add(1, "hour");
const form = getFormDates(start.format(), end.format());

const schedule = mapToBackend({
startDate: form.startDate,
endDate: form.endDate,
startTime: form.startTime,
endTime: form.endTime,
isAllDay: false,
});

expect(schedule.kind).toBe("timed");
if (schedule.kind !== "timed") return;

expect(dayjs(schedule.start).format("YYYY-MM-DD HH:mm")).toBe(
"2026-08-11 14:00",
);
expect(dayjs(schedule.end).format("YYYY-MM-DD HH:mm")).toBe(
"2026-08-11 15:00",
);
});

it("accepts explicit same-day start/end dates with same-day clock times", () => {
const day = dayjs("2026-08-11T00:00:00");
const schedule = mapToBackend({
startDate: day.toDate(),
endDate: day.toDate(),
startTime: getTimeOptionByValue(day.hour(9).minute(0)),
endTime: getTimeOptionByValue(day.hour(10).minute(0)),
isAllDay: false,
});

expect(schedule.kind).toBe("timed");
if (schedule.kind !== "timed") return;
expect(dayjs(schedule.end).isAfter(dayjs(schedule.start))).toBe(true);
});
});
5 changes: 4 additions & 1 deletion packages/web/src/common/utils/datetime/web.date.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,11 @@ const _addTimesToDates = (dt: SelectedDates, timeZone: string) => {
.format();

const end = getDayjsByTimeValue(dt.endTime.value);
// Use endDate (not startDate) so overnight / multi-day timed drafts keep
// their real end calendar day. Applying the end clock time onto startDate
// made 11:30 PM → 12:30 AM parse as inverted and blocked Save.
const endDate = dayjs
.tz(dt.startDate, timeZone)
.tz(dt.endDate, timeZone)
.hour(end.hour())
.minute(end.minute())
.second(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,13 @@ describe("getNavigationCommandItems", () => {
expect(useKeyboardOnlyStore.getState().isActive).toBe(false);
});

it("advertises Shift Shift as the shortcut", () => {
it("advertises h as the shortcut", () => {
const item = getNavigationCommandItems({
onNavigateToView: () => {},
}).find((entry) => entry.id === "enter-keyboard-only");

expect(item?.label).toBe("Toggle Hardcore Mode");
expect(item?.shortcut).toEqual(["Shift", "Shift"]);
expect(item?.shortcut).toEqual(["h"]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export const getNavigationCommandItems = ({
id: "enter-keyboard-only",
label: "Toggle Hardcore Mode",
icon: KeyboardIcon,
shortcut: ["Shift", "Shift"],
shortcut: ["h"],
keywords: [
"keyboard",
"hardcore",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@ describe("onboarding tour steps", () => {
);

expect(hardcore?.title).toMatch(/Graduate/i);
expect(hardcore?.body).toMatch(/Shift twice/i);
expect(hardcore?.body).toMatch(/Press H/i);
expect(hardcore?.body).toMatch(/keyboard-only/i);
expect(hardcore?.body).toMatch(/clicks/i);
expect(hardcore?.body).toMatch(/command palette/i);
expect(hardcore?.shortcutHint).toEqual(["Shift", "Shift"]);
expect(hardcore?.shortcutHint).toBe("H");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] {
},
targetEvent: {
title: "Jump to Dentist",
body: "Tap Shift once to flash a key over every visible event, then press Dentist's key to jump straight to it. Great when there are a few on the same day.",
shortcutHint: "Shift",
body: "Tap S to flash a key over every visible event, then press Dentist's key to jump straight to it. Great when there are a few on the same day.",
shortcutHint: "S",
},
move: {
title: "Move Dentist out of the overlap",
Expand All @@ -90,8 +90,8 @@ export function getOnboardingTourSteps(): OnboardingTourStep[] {
},
hardcore: {
title: "Graduate to Hardcore Mode",
body: "Press Shift twice to go keyboard-only, clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.",
shortcutHint: ["Shift", "Shift"],
body: "Press H to go keyboard-only, clicks stay off until you exit. Sample events are already on your calendar. Reopen this tour from the command palette anytime.",
shortcutHint: "H",
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { keyboardOnlyActions } from "@web/shortcuts/keyboard-only/keyboard-only.

/**
* Mission steps that target a real grid event: mouse clicks disable for
* these on top of the existing double-Shift gesture in useKeyboardOnlyMode.ts,
* these on top of the existing `h` Hardcore toggle in useKeyboardOnlyMode.ts,
* so the keyboard is the only way through. `undo`/`hardcore` don't touch a
* grid event directly and stay mouse-permissive; `create`/`save` are the
* very first lesson and stay permissive too.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import {
selectKeyboardOnlyActive,
useKeyboardOnlyStore,
} from "@web/shortcuts/keyboard-only/keyboard-only.store";
import { resetSharedShiftTapGesture } from "@web/shortcuts/shift-tap-gesture";

const TITLE_FIELD_SELECTOR = `form[name="${ID_EVENT_FORM}"] input[name="Event Title"]`;

Expand Down Expand Up @@ -255,15 +254,10 @@ export function useOnboardingTourProgress() {
}, [isActive, stepId, draftActivity, gridDraft]);

// undo: two phases against real event state, not keydowns - wait for
// Dentist's schedule to change (undo), then to change again (redo). Also
// resets the shared Shift-tap gesture: Act 2's several prior Shift+Arrow
// missions can leave it mid-cycle, and an in-flight arm treats the very
// next non-arrow keydown as a (failed) day-jump letter and swallows it -
// which is exactly what Mod+Shift+Z's "Z" looks like to that listener.
// Dentist's schedule to change (undo), then to change again (redo).
// biome-ignore lint/correctness/useExhaustiveDependencies: capture the schedule once, on step entry - dentistEvent updating mid-step must not reset the snapshot we diff against.
useEffect(() => {
if (!isActive || stepId !== "undo") return;
resetSharedShiftTapGesture();
enterDentistMission(dentistEvent, scheduleAtEntryRef);
undoPhaseRef.current = "pending-undo";
}, [isActive, stepId]);
Expand Down
Loading