From d2c989663eb1e9ae4053b0c223e8eba2347408fb Mon Sep 17 00:00:00 2001 From: Maikel Hajiabadi <89179997+hajiboy95@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:01:00 +0200 Subject: [PATCH] feat(shortcuts): add optional shortcuts for scheduling #8093 (#8189) * feat(shortcuts): add optional shortcuts for scheduling tasks to tomorrow, next week, and next month #8093 This change adds configurable, unassigned-by-default keyboard shortcuts for scheduling tasks to Tomorrow, Next Week, and Next Month. It also renames the existing 'moveToTodaysTasks' shortcut to 'taskScheduleToday' to align with the new schedule shortcuts. Closes #8093 * feat: add migration for renamed keyboard shortcut moveToTodaysTasks to taskScheduleToday * docs: fix corrupted fragment in keyboard shortcuts wiki * test: add unit tests for task scheduling shortcuts and fix migration typing * refactor: use getNextWeekDayOffset helper and preserve time/reminders for timed tasks * docs: clarify Next Month shortcut behavior * fix(config): ensure keyboard shortcut migration is one-shot by stripping legacy keys * fix(tasks): preserve reminder offset and show snack when rescheduling timed tasks * fix(tasks): fix regression in scheduleTask call and add missing snackbar confirmation * fix(tasks): align scheduling snack with planner effect formatting Use LocaleDatePipe.shortDate and include getSnackExtraStr() so the timed-task reschedule snack matches planTaskForDay's snack output. --------- Co-authored-by: johannesjo --- docs/wiki/3.03-Keyboard-Shortcuts.md | 4 +- .../config/default-global-config.const.ts | 5 +- .../config/form-cfgs/keyboard-form.const.ts | 5 +- .../features/config/keyboard-config.model.ts | 5 +- .../store/global-config.reducer.spec.ts | 92 +++++++++++ .../config/store/global-config.reducer.ts | 18 +- .../dialog-deadline.component.ts | 7 +- .../task-context-menu-inner.component.html | 2 +- .../task-context-menu-inner.component.ts | 7 +- .../tasks/task-shortcut.service.spec.ts | 2 +- .../features/tasks/task-shortcut.service.ts | 17 +- .../task-hover-controls.component.html | 2 +- .../tasks/task/task-shortcuts.spec.ts | 154 +++++++++++++++++- src/app/features/tasks/task/task.component.ts | 70 +++++++- src/app/t.const.ts | 5 +- src/app/util/get-next-week-day-offset.ts | 8 + src/assets/i18n/en.json | 5 +- .../test-data/super-productivity-overdue.json | 2 +- 18 files changed, 380 insertions(+), 30 deletions(-) create mode 100644 src/app/util/get-next-week-day-offset.ts diff --git a/docs/wiki/3.03-Keyboard-Shortcuts.md b/docs/wiki/3.03-Keyboard-Shortcuts.md index 2fc32a591b..d7f61b61c5 100755 --- a/docs/wiki/3.03-Keyboard-Shortcuts.md +++ b/docs/wiki/3.03-Keyboard-Shortcuts.md @@ -119,6 +119,8 @@ Shortcuts are stored in global config (`GlobalConfigState.keyboard`) and persist **Task-level shortcuts:** Actions with a task focus (e.g. mark done, add subtask, schedule, delete, move) apply to the currently selected task. Selection is by keyboard (tab/arrows) or mouse. The [[3.02-Settings-and-Preferences]] keyboard help text describes these as applying to "the currently selected task." +Specifically for the **Next Month** shortcut, the task is scheduled for the **1st day of the next month**. This avoids issues with different month lengths (e.g., scheduling for the 31st when the next month only has 30 days). + **Schedule week view:** `Ctrl`+MouseWheel changes the vertical scale of the week schedule grid. The value is stored locally for future schedule visits. **Focus Mode overlay:** `Escape` closes the overlay. If a focus session or break is running, it continues in the header focus button. @@ -128,7 +130,7 @@ Shortcuts are stored in global config (`GlobalConfigState.keyboard`) and persist ## Conflict and Precedence - **Input focus:** When focus is in an input element or a blocking overlay is open, shortcut handling is skipped so typing is not interrupted. -- **Contextual Precedence:** Task-level shortcuts take precedence over global shortcuts when a task is currently focused. For example, pressing `Shift+S` opens the deadline dialog if a task is focused, but navigates to the Schedule view if no task is focused. Similarly, `Shift+T` moves the selected task to Today, but navigates to the Today view otherwise. +- **Contextual Precedence:** Task-level shortcuts take precedence over global shortcuts when a task is currently focused. For example, pressing `Shift+S` opens the deadline dialog if a task is focused, but navigates to the Schedule view if no task is focused. Similarly, `Shift+T` schedules the selected task for today, but navigates to the Today view otherwise. - **Conflicts:** The configuration UI warns if two actions are assigned the same key combination. The user can press **Escape**, **Backspace**, or **Delete** in the shortcut field to clear an existing assignment. ## Storage and Definition diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index f26e2356e2..7faf15a3e2 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -160,6 +160,10 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { taskOpenNotesFullscreen: null, taskOpenEstimationDialog: 'T', taskSchedule: 'S', + taskScheduleToday: 'Shift+T', + taskScheduleTomorrow: null, + taskScheduleNextWeek: null, + taskScheduleNextMonth: null, taskScheduleDeadline: 'Shift+S', taskUnschedule: 'U', taskToggleDone: 'D', @@ -175,7 +179,6 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { moveTaskToTop: 'Ctrl+Alt+ArrowUp', moveTaskToBottom: 'Ctrl+Alt+ArrowDown', moveToBacklog: 'Shift+B', - moveToTodaysTasks: 'Shift+T', expandSubTasks: null, collapseSubTasks: null, togglePlay: 'Y', diff --git a/src/app/features/config/form-cfgs/keyboard-form.const.ts b/src/app/features/config/form-cfgs/keyboard-form.const.ts index dcd9906e24..67d5dd3159 100644 --- a/src/app/features/config/form-cfgs/keyboard-form.const.ts +++ b/src/app/features/config/form-cfgs/keyboard-form.const.ts @@ -97,6 +97,10 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection = { kbField('taskOpenNotesPanel', T.GCF.KEYBOARD.TASK_OPEN_NOTES_PANEL), kbField('taskOpenEstimationDialog', T.GCF.KEYBOARD.TASK_OPEN_ESTIMATION_DIALOG), kbField('taskSchedule', T.GCF.KEYBOARD.TASK_SCHEDULE), + kbField('taskScheduleToday', T.GCF.KEYBOARD.TASK_SCHEDULE_TODAY), + kbField('taskScheduleTomorrow', T.GCF.KEYBOARD.TASK_SCHEDULE_TOMORROW), + kbField('taskScheduleNextWeek', T.GCF.KEYBOARD.TASK_SCHEDULE_NEXT_WEEK), + kbField('taskScheduleNextMonth', T.GCF.KEYBOARD.TASK_SCHEDULE_NEXT_MONTH), kbField('taskScheduleDeadline', T.GCF.KEYBOARD.TASK_SCHEDULE_DEADLINE), kbField('taskUnschedule', T.GCF.KEYBOARD.TASK_UNSCHEDULE), kbField('taskToggleDone', T.GCF.KEYBOARD.TASK_TOGGLE_DONE), @@ -113,7 +117,6 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection = { kbField('moveTaskToTop', T.GCF.KEYBOARD.MOVE_TASK_TO_TOP), kbField('moveTaskToBottom', T.GCF.KEYBOARD.MOVE_TASK_TO_BOTTOM), kbField('moveToBacklog', T.GCF.KEYBOARD.MOVE_TO_BACKLOG), - kbField('moveToTodaysTasks', T.GCF.KEYBOARD.MOVE_TO_REGULARS_TASKS), kbField('expandSubTasks', T.GCF.KEYBOARD.EXPAND_SUB_TASKS), kbField('collapseSubTasks', T.GCF.KEYBOARD.COLLAPSE_SUB_TASKS), kbField('togglePlay', T.GCF.KEYBOARD.TOGGLE_PLAY), diff --git a/src/app/features/config/keyboard-config.model.ts b/src/app/features/config/keyboard-config.model.ts index 6a1d02c464..db5f3492a9 100644 --- a/src/app/features/config/keyboard-config.model.ts +++ b/src/app/features/config/keyboard-config.model.ts @@ -37,6 +37,10 @@ export type KeyboardConfig = Readonly<{ taskOpenContextMenu?: string | null; taskDelete?: string | null; taskSchedule?: string | null; + taskScheduleToday?: string | null; + taskScheduleTomorrow?: string | null; + taskScheduleNextWeek?: string | null; + taskScheduleNextMonth?: string | null; taskScheduleDeadline?: string | null; taskUnschedule?: string | null; selectPreviousTask?: string | null; @@ -46,7 +50,6 @@ export type KeyboardConfig = Readonly<{ moveTaskToTop?: string | null; moveTaskToBottom?: string | null; moveToBacklog?: string | null; - moveToTodaysTasks?: string | null; expandSubTasks?: string | null; collapseSubTasks?: string | null; togglePlay?: string | null; diff --git a/src/app/features/config/store/global-config.reducer.spec.ts b/src/app/features/config/store/global-config.reducer.spec.ts index edda76ef16..23e32a3b66 100644 --- a/src/app/features/config/store/global-config.reducer.spec.ts +++ b/src/app/features/config/store/global-config.reducer.spec.ts @@ -234,6 +234,98 @@ describe('GlobalConfigReducer', () => { expect(result.keyboard.addNewNote).toBe('N'); expect(result.keyboard.taskOpenNotesPanel).toBe('Alt+Shift+N'); }); + + describe('moveToTodaysTasks migration', () => { + it('should migrate moveToTodaysTasks to taskScheduleToday', () => { + const legacyConfig = { + ...initialGlobalConfigState, + keyboard: { + ...initialGlobalConfigState.keyboard, + moveToTodaysTasks: 'Shift+T', + taskScheduleToday: null, + }, + }; + + const result = globalConfigReducer( + initialGlobalConfigState, + loadAllData({ + appDataComplete: { + globalConfig: legacyConfig, + } as unknown as AppDataComplete, + }), + ); + + expect(result.keyboard.taskScheduleToday).toBe('Shift+T'); + expect((result.keyboard as any).moveToTodaysTasks).toBeUndefined(); + }); + + it('should NOT re-migrate if taskScheduleToday is already null (manually disabled)', () => { + const legacyConfigWithBoth = { + ...initialGlobalConfigState, + keyboard: { + ...initialGlobalConfigState.keyboard, + moveToTodaysTasks: 'Shift+T', + taskScheduleToday: null, + }, + }; + + // First migration + const result1 = globalConfigReducer( + initialGlobalConfigState, + loadAllData({ + appDataComplete: { + globalConfig: legacyConfigWithBoth, + } as unknown as AppDataComplete, + }), + ); + expect(result1.keyboard.taskScheduleToday).toBe('Shift+T'); + expect((result1.keyboard as any).moveToTodaysTasks).toBeUndefined(); + + // User disables it + const configWithDisabled = { + ...result1, + keyboard: { + ...result1.keyboard, + taskScheduleToday: null, + }, + }; + + // Second load (e.g. restart) + const result2 = globalConfigReducer( + initialGlobalConfigState, + loadAllData({ + appDataComplete: { + globalConfig: configWithDisabled, + } as unknown as AppDataComplete, + }), + ); + + expect(result2.keyboard.taskScheduleToday).toBeNull(); + }); + + it('should strip moveToTodaysTasks even if no migration is needed', () => { + const legacyConfig = { + ...initialGlobalConfigState, + keyboard: { + ...initialGlobalConfigState.keyboard, + moveToTodaysTasks: 'Shift+T', + taskScheduleToday: 'Ctrl+T', + }, + }; + + const result = globalConfigReducer( + initialGlobalConfigState, + loadAllData({ + appDataComplete: { + globalConfig: legacyConfig, + } as unknown as AppDataComplete, + }), + ); + + expect(result.keyboard.taskScheduleToday).toBe('Ctrl+T'); + expect((result.keyboard as any).moveToTodaysTasks).toBeUndefined(); + }); + }); }); it('should use syncProvider from snapshot when oldState has null (initial load)', () => { diff --git a/src/app/features/config/store/global-config.reducer.ts b/src/app/features/config/store/global-config.reducer.ts index c69ded339b..f97f3cee38 100644 --- a/src/app/features/config/store/global-config.reducer.ts +++ b/src/app/features/config/store/global-config.reducer.ts @@ -106,14 +106,24 @@ export const initialGlobalConfigState: GlobalConfigState = { }; const migrateKeyboardConfig = (cfg: KeyboardConfig | undefined): KeyboardConfig => { - const keyboard: KeyboardConfig = { + const { moveToTodaysTasks, ...rest } = + (cfg as (KeyboardConfig & { moveToTodaysTasks?: string | null }) | undefined) ?? {}; + + let keyboard: KeyboardConfig = { ...DEFAULT_GLOBAL_CONFIG.keyboard, - ...cfg, + ...rest, }; + if (moveToTodaysTasks != null && rest.taskScheduleToday == null) { + keyboard = { + ...keyboard, + taskScheduleToday: moveToTodaysTasks, + }; + } + if ( - cfg?.addNewNote === 'N' && - (cfg.taskOpenNotesPanel === undefined || cfg.taskOpenNotesPanel === null) + rest.addNewNote === 'N' && + (rest.taskOpenNotesPanel === undefined || rest.taskOpenNotesPanel === null) ) { return { ...keyboard, diff --git a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts index 3593a06bac..90fca9fbcb 100644 --- a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts +++ b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts @@ -26,6 +26,7 @@ import { isValidSplitTime } from '../../../util/is-valid-split-time'; import { normalizeClockStr } from '../../../util/normalize-clock-str'; import { getDbDateStr } from '../../../util/get-db-date-str'; import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date'; +import { getNextWeekDayOffset } from '../../../util/get-next-week-day-offset'; import { DateService } from '../../../core/date/date.service'; import { DateAdapter } from '@angular/material/core'; import { MatButton } from '@angular/material/button'; @@ -235,11 +236,7 @@ export class DialogDeadlineComponent implements AfterViewInit { d.setDate(d.getDate() + 1); return d; case 'nextWeek': { - const dayOffset = - (this._dateAdapter.getFirstDayOfWeek() - - this._dateAdapter.getDayOfWeek(d) + - 7) % - 7 || 7; + const dayOffset = getNextWeekDayOffset(this._dateAdapter, d); d.setDate(d.getDate() + dayOffset); return d; } diff --git a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.html b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.html index 3902acb2c5..56ebd53965 100644 --- a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.html +++ b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.html @@ -247,7 +247,7 @@ arrow_upward {{ T.F.TASK.CMP.MOVE_TO_REGULAR | translate }} - {{ kb.moveToTodaysTasks }} + {{ kb.taskScheduleToday }} } diff --git a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts index 45dda57742..58261ec09f 100644 --- a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts +++ b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts @@ -64,6 +64,7 @@ import { getDbDateStr } from '../../../../util/get-db-date-str'; import { PlannerActions } from '../../../planner/store/planner.actions'; import { addSubTask } from '../../../tasks/store/task.actions'; import { combineDateAndTime } from '../../../../util/combine-date-and-time'; +import { getNextWeekDayOffset } from '../../../../util/get-next-week-day-offset'; import { DateAdapter } from '@angular/material/core'; import { ICAL_TYPE } from '../../../issue/issue.const'; import { IssueIconPipe } from '../../../issue/issue-icon/issue-icon.pipe'; @@ -723,11 +724,7 @@ export class TaskContextMenuInnerComponent implements AfterViewInit, OnDestroy { break; case 3: const nextFirstDayOfWeek = tDate; - const dayOffset = - (this._dateAdapter.getFirstDayOfWeek() - - this._dateAdapter.getDayOfWeek(nextFirstDayOfWeek) + - 7) % - 7 || 7; + const dayOffset = getNextWeekDayOffset(this._dateAdapter, nextFirstDayOfWeek); nextFirstDayOfWeek.setDate(nextFirstDayOfWeek.getDate() + dayOffset); this._schedule(nextFirstDayOfWeek); break; diff --git a/src/app/features/tasks/task-shortcut.service.spec.ts b/src/app/features/tasks/task-shortcut.service.spec.ts index 7e55efc5b7..db39a8c00d 100644 --- a/src/app/features/tasks/task-shortcut.service.spec.ts +++ b/src/app/features/tasks/task-shortcut.service.spec.ts @@ -37,7 +37,7 @@ describe('TaskShortcutService', () => { taskEditTags: 'G', taskOpenContextMenu: null, moveToBacklog: 'B', - moveToTodaysTasks: 'F', + taskScheduleToday: 'F', selectPreviousTask: 'K', selectNextTask: 'J', collapseSubTasks: 'H', diff --git a/src/app/features/tasks/task-shortcut.service.ts b/src/app/features/tasks/task-shortcut.service.ts index 9509f8f501..11f7e6d65a 100644 --- a/src/app/features/tasks/task-shortcut.service.ts +++ b/src/app/features/tasks/task-shortcut.service.ts @@ -169,6 +169,21 @@ export class TaskShortcutService { ev.preventDefault(); return true; } + if (checkKeyCombo(ev, keys.taskScheduleTomorrow)) { + this._handleTaskShortcut(focusedTaskId, 'scheduleTaskTomorrow'); + ev.preventDefault(); + return true; + } + if (checkKeyCombo(ev, keys.taskScheduleNextWeek)) { + this._handleTaskShortcut(focusedTaskId, 'scheduleTaskNextWeek'); + ev.preventDefault(); + return true; + } + if (checkKeyCombo(ev, keys.taskScheduleNextMonth)) { + this._handleTaskShortcut(focusedTaskId, 'scheduleTaskNextMonth'); + ev.preventDefault(); + return true; + } if (checkKeyCombo(ev, keys.taskScheduleDeadline)) { this._handleTaskShortcut(focusedTaskId, 'openDeadlineDialog'); ev.preventDefault(); @@ -229,7 +244,7 @@ export class TaskShortcutService { return true; } - if (checkKeyCombo(ev, keys.moveToTodaysTasks)) { + if (checkKeyCombo(ev, keys.taskScheduleToday)) { this._handleTaskShortcut(focusedTaskId, 'moveToTodayWithFocus'); ev.preventDefault(); ev.stopPropagation(); diff --git a/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.html b/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.html index 62e9799e09..4093eb239a 100644 --- a/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.html +++ b/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.html @@ -29,7 +29,7 @@