mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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 <johannes.millan@gmail.com>
This commit is contained in:
parent
595e7cef2a
commit
d2c989663e
18 changed files with 380 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection<KeyboardConfig> = {
|
|||
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<KeyboardConfig> = {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@
|
|||
<mat-icon>arrow_upward</mat-icon>
|
||||
{{ T.F.TASK.CMP.MOVE_TO_REGULAR | translate }}
|
||||
</span>
|
||||
<span class="key-i">{{ kb.moveToTodaysTasks }}</span>
|
||||
<span class="key-i">{{ kb.taskScheduleToday }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ describe('TaskShortcutService', () => {
|
|||
taskEditTags: 'G',
|
||||
taskOpenContextMenu: null,
|
||||
moveToBacklog: 'B',
|
||||
moveToTodaysTasks: 'F',
|
||||
taskScheduleToday: 'F',
|
||||
selectPreviousTask: 'K',
|
||||
selectNextTask: 'J',
|
||||
collapseSubTasks: 'H',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
<button
|
||||
(click)="parent.addToMyDay()"
|
||||
title="{{ T.F.TASK.CMP.ADD_TO_MY_DAY | translate }} {{
|
||||
kb.moveToTodaysTasks ? '[' + kb.moveToTodaysTasks + ']' : ''
|
||||
kb.taskScheduleToday ? '[' + kb.taskScheduleToday + ']' : ''
|
||||
}}"
|
||||
class="ico-btn"
|
||||
color=""
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { TestBed } from '@angular/core/testing';
|
|||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of } from 'rxjs';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
|
||||
import { LayoutService } from '../../../core-ui/layout/layout.service';
|
||||
|
|
@ -15,6 +17,10 @@ import { DEFAULT_TASK, HideSubTasksMode, TaskWithSubTasks } from '../task.model'
|
|||
import { TaskService } from '../task.service';
|
||||
import { WorkContextService } from '../../work-context/work-context.service';
|
||||
import { TaskComponent } from './task.component';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { LocaleDatePipe } from '../../../ui/pipes/locale-date.pipe';
|
||||
import { PlannerService } from '../../planner/planner.service';
|
||||
|
||||
describe('TaskComponent shortcut handling', () => {
|
||||
let fixture: import('@angular/core/testing').ComponentFixture<TaskComponent>;
|
||||
|
|
@ -65,6 +71,7 @@ describe('TaskComponent shortcut handling', () => {
|
|||
'pauseCurrent',
|
||||
'getByIdWithSubTaskData$',
|
||||
'focusTaskById',
|
||||
'scheduleTask',
|
||||
],
|
||||
{
|
||||
currentTaskId: signal<string | null>(null),
|
||||
|
|
@ -101,7 +108,7 @@ describe('TaskComponent shortcut handling', () => {
|
|||
{
|
||||
provide: GlobalConfigService,
|
||||
useValue: jasmine.createSpyObj('GlobalConfigService', ['cfg'], {
|
||||
cfg: () => ({ keyboard: {}, tasks: {} }),
|
||||
cfg: () => ({ keyboard: {}, tasks: {}, reminder: {} }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
|
@ -112,6 +119,22 @@ describe('TaskComponent shortcut handling', () => {
|
|||
]),
|
||||
},
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{
|
||||
provide: SnackService,
|
||||
useValue: jasmine.createSpyObj('SnackService', ['open']),
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: jasmine.createSpyObj('TranslateService', ['instant']),
|
||||
},
|
||||
{
|
||||
provide: LocaleDatePipe,
|
||||
useValue: jasmine.createSpyObj('LocaleDatePipe', ['transform']),
|
||||
},
|
||||
{
|
||||
provide: PlannerService,
|
||||
useValue: jasmine.createSpyObj('PlannerService', ['getSnackExtraStr']),
|
||||
},
|
||||
{
|
||||
provide: ProjectService,
|
||||
useValue: jasmine.createSpyObj('ProjectService', [
|
||||
|
|
@ -130,9 +153,13 @@ describe('TaskComponent shortcut handling', () => {
|
|||
},
|
||||
{
|
||||
provide: DateService,
|
||||
useValue: jasmine.createSpyObj('DateService', ['isToday'], {
|
||||
isToday: () => false,
|
||||
}),
|
||||
useValue: jasmine.createSpyObj(
|
||||
'DateService',
|
||||
['isToday', 'getLogicalTodayDate'],
|
||||
{
|
||||
isToday: () => false,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
provide: GlobalTrackingIntervalService,
|
||||
|
|
@ -152,6 +179,13 @@ describe('TaskComponent shortcut handling', () => {
|
|||
isTodayList: signal(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DateAdapter,
|
||||
useValue: jasmine.createSpyObj('DateAdapter', [
|
||||
'getFirstDayOfWeek',
|
||||
'getDayOfWeek',
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(TaskComponent, {
|
||||
|
|
@ -337,4 +371,116 @@ describe('TaskComponent shortcut handling', () => {
|
|||
|
||||
expect(taskServiceSpy.addSubTaskTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Scheduling shortcuts', () => {
|
||||
let dateService: jasmine.SpyObj<DateService>;
|
||||
let dateAdapter: jasmine.SpyObj<DateAdapter<unknown>>;
|
||||
let plannerService: jasmine.SpyObj<PlannerService>;
|
||||
|
||||
beforeEach(() => {
|
||||
dateService = TestBed.inject(DateService) as jasmine.SpyObj<DateService>;
|
||||
dateAdapter = TestBed.inject(DateAdapter) as jasmine.SpyObj<DateAdapter<unknown>>;
|
||||
plannerService = TestBed.inject(PlannerService) as jasmine.SpyObj<PlannerService>;
|
||||
// Mock "logical today" to 2026-06-01 (a Monday)
|
||||
dateService.getLogicalTodayDate.and.returnValue(new Date('2026-06-01T12:00:00'));
|
||||
dateAdapter.getDayOfWeek.and.callFake((d: any) => (d as Date).getDay());
|
||||
dateAdapter.getFirstDayOfWeek.and.returnValue(1); // Monday
|
||||
plannerService.getSnackExtraStr.and.returnValue(Promise.resolve(''));
|
||||
});
|
||||
|
||||
it('schedules for tomorrow', () => {
|
||||
component.scheduleTaskTomorrow();
|
||||
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-06-02',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules for next week (next Monday)', () => {
|
||||
component.scheduleTaskNextWeek();
|
||||
|
||||
// Next week from Monday June 1st should be June 8th
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-06-08',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules for next week (from Sunday, next Monday)', () => {
|
||||
dateService.getLogicalTodayDate.and.returnValue(new Date('2026-06-07T12:00:00')); // Sunday
|
||||
|
||||
component.scheduleTaskNextWeek();
|
||||
|
||||
// Next week from Sunday June 7th (first day Monday) should be June 8th
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-06-08',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules for next week (from Sunday, next Monday) - US locale (Sunday first)', () => {
|
||||
dateAdapter.getFirstDayOfWeek.and.returnValue(0); // Sunday
|
||||
dateService.getLogicalTodayDate.and.returnValue(new Date('2026-06-07T12:00:00')); // Sunday
|
||||
|
||||
component.scheduleTaskNextWeek();
|
||||
|
||||
// Next week from Sunday June 7th (first day Sunday) should be June 14th
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-06-14',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules for next month (first of next month)', () => {
|
||||
component.scheduleTaskNextMonth();
|
||||
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-07-01',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves time and reminder when scheduling a timed task for tomorrow', async () => {
|
||||
const timedTask = {
|
||||
...component.task(),
|
||||
dueWithTime: new Date('2026-06-01T10:00:00').getTime(),
|
||||
};
|
||||
fixture.componentRef.setInput('task', timedTask);
|
||||
|
||||
await component.scheduleTaskTomorrow();
|
||||
|
||||
// Should call taskService.scheduleTask instead of dispatching planTaskForDay
|
||||
// June 2nd at 10:00:00
|
||||
expect(taskServiceSpy.scheduleTask).toHaveBeenCalledWith(
|
||||
timedTask as any,
|
||||
new Date('2026-06-02T10:00:00').getTime(),
|
||||
jasmine.any(String),
|
||||
false,
|
||||
);
|
||||
expect(TestBed.inject(SnackService).open).toHaveBeenCalled();
|
||||
expect(storeSpy.dispatch).not.toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: timedTask as any,
|
||||
day: '2026-06-02',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,13 +57,19 @@ import { TaskRepeatCfgService } from '../../task-repeat-cfg/task-repeat-cfg.serv
|
|||
import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component';
|
||||
import { DialogFullscreenMarkdownComponent } from '../../../ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component';
|
||||
import { Update } from '@ngrx/entity';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
|
||||
import { combineDateAndTime } from '../../../util/combine-date-and-time';
|
||||
import { getNextWeekDayOffset } from '../../../util/get-next-week-day-offset';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { isTouchActive } from '../../../util/input-intent';
|
||||
import { IS_HYBRID_DEVICE } from '../../../util/is-mouse-primary';
|
||||
import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants';
|
||||
import { KeyboardConfig } from '../../config/keyboard-config.model';
|
||||
import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { PlannerService } from '../../planner/planner.service';
|
||||
import { DialogDeadlineComponent } from '../dialog-deadline/dialog-deadline.component';
|
||||
import { isDeadlineOverdue as isDeadlineOverdueFn } from '../util/is-deadline-overdue';
|
||||
import { isDeadlineApproaching as isDeadlineApproachingFn } from '../util/is-deadline-approaching';
|
||||
|
|
@ -79,7 +85,8 @@ import { TaskListComponent } from '../task-list/task-list.component';
|
|||
import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe';
|
||||
import { ShortPlannedAtPipe } from '../../../ui/pipes/short-planned-at.pipe';
|
||||
import { LocalDateStrPipe } from '../../../ui/pipes/local-date-str.pipe';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LocaleDatePipe } from '../../../ui/pipes/locale-date.pipe';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { SubTaskTotalTimeSpentPipe } from '../pipes/sub-task-total-time-spent.pipe';
|
||||
import { TagListComponent } from '../../tag/tag-list/tag-list.component';
|
||||
import { TagToggleMenuListComponent } from '../../tag/tag-toggle-menu-list/tag-toggle-menu-list.component';
|
||||
|
|
@ -93,8 +100,10 @@ import { LayoutService } from '../../../core-ui/layout/layout.service';
|
|||
import { TaskFocusService } from '../task-focus.service';
|
||||
import { selectTimeConflictTaskIds } from '../store/task.selectors';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { millisecondsDiffToRemindOption } from '../util/remind-option-to-milliseconds';
|
||||
import { MenuTreeService } from '../../menu-tree/menu-tree.service';
|
||||
import { SelectOptionRowComponent } from '../../../ui/select-option-row/select-option-row.component';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
|
||||
@Component({
|
||||
selector: 'task',
|
||||
|
|
@ -148,12 +157,17 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
|
|||
private readonly _configService = inject(GlobalConfigService);
|
||||
private readonly _attachmentService = inject(TaskAttachmentService);
|
||||
private readonly _elementRef = inject(ElementRef);
|
||||
private readonly _dateAdapter = inject(DateAdapter);
|
||||
private readonly _store = inject(Store);
|
||||
private readonly _projectService = inject(ProjectService);
|
||||
private readonly _taskFocusService = inject(TaskFocusService);
|
||||
private readonly _dateService = inject(DateService);
|
||||
private readonly _destroyRef = inject(DestroyRef);
|
||||
private readonly _menuTreeService = inject(MenuTreeService);
|
||||
private readonly _snackService = inject(SnackService);
|
||||
private readonly _translateService = inject(TranslateService);
|
||||
private readonly _datePipe = inject(LocaleDatePipe);
|
||||
private readonly _plannerService = inject(PlannerService);
|
||||
|
||||
readonly workContextService = inject(WorkContextService);
|
||||
readonly layoutService = inject(LayoutService);
|
||||
|
|
@ -506,6 +520,60 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
|
|||
});
|
||||
}
|
||||
|
||||
async scheduleTaskTomorrow(): Promise<void> {
|
||||
const tDate = this._dateService.getLogicalTodayDate();
|
||||
tDate.setDate(tDate.getDate() + 1);
|
||||
await this._scheduleForDay(tDate);
|
||||
}
|
||||
|
||||
async scheduleTaskNextWeek(): Promise<void> {
|
||||
const tDate = this._dateService.getLogicalTodayDate();
|
||||
const dayOffset = getNextWeekDayOffset(this._dateAdapter, tDate);
|
||||
tDate.setDate(tDate.getDate() + dayOffset);
|
||||
await this._scheduleForDay(tDate);
|
||||
}
|
||||
|
||||
async scheduleTaskNextMonth(): Promise<void> {
|
||||
const tDate = this._dateService.getLogicalTodayDate();
|
||||
tDate.setDate(1);
|
||||
tDate.setMonth(tDate.getMonth() + 1);
|
||||
await this._scheduleForDay(tDate);
|
||||
}
|
||||
|
||||
private async _scheduleForDay(dayDate: Date): Promise<void> {
|
||||
const day = getDbDateStr(dayDate);
|
||||
const task = this.task();
|
||||
if (task.dueWithTime) {
|
||||
const newDate = combineDateAndTime(dayDate, new Date(task.dueWithTime));
|
||||
const remindCfg = task.reminderId
|
||||
? millisecondsDiffToRemindOption(task.dueWithTime, task.remindAt)
|
||||
: (this._configService.cfg()?.reminder.defaultTaskRemindOption ??
|
||||
DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!);
|
||||
|
||||
this._taskService.scheduleTask(task, newDate.getTime(), remindCfg, false);
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: T.F.PLANNER.S.TASK_PLANNED_FOR,
|
||||
ico: 'today',
|
||||
translateParams: {
|
||||
date: this._dateService.isToday(newDate)
|
||||
? this._translateService.instant(T.G.TODAY_TAG_TITLE)
|
||||
: (this._datePipe.transform(day, 'shortDate') as string),
|
||||
extra: await this._plannerService.getSnackExtraStr(day),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this._store.dispatch(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: task as TaskCopy,
|
||||
day,
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.focusSelfOrNextIfNotPossible();
|
||||
}
|
||||
|
||||
async editTaskRepeatCfg(): Promise<void> {
|
||||
const { DialogEditTaskRepeatCfgComponent } =
|
||||
await import('../../task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component');
|
||||
|
|
|
|||
|
|
@ -2428,7 +2428,6 @@ const T = {
|
|||
MOVE_TASK_TO_TOP: 'GCF.KEYBOARD.MOVE_TASK_TO_TOP',
|
||||
MOVE_TASK_UP: 'GCF.KEYBOARD.MOVE_TASK_UP',
|
||||
MOVE_TO_BACKLOG: 'GCF.KEYBOARD.MOVE_TO_BACKLOG',
|
||||
MOVE_TO_REGULARS_TASKS: 'GCF.KEYBOARD.MOVE_TO_REGULARS_TASKS',
|
||||
OPEN_PROJECT_NOTES: 'GCF.KEYBOARD.OPEN_PROJECT_NOTES',
|
||||
PLUGIN_SHORTCUTS: 'GCF.KEYBOARD.PLUGIN_SHORTCUTS',
|
||||
SELECT_NEXT_TASK: 'GCF.KEYBOARD.SELECT_NEXT_TASK',
|
||||
|
|
@ -2444,6 +2443,10 @@ const T = {
|
|||
TASK_OPEN_CONTEXT_MENU: 'GCF.KEYBOARD.TASK_OPEN_CONTEXT_MENU',
|
||||
TASK_OPEN_ESTIMATION_DIALOG: 'GCF.KEYBOARD.TASK_OPEN_ESTIMATION_DIALOG',
|
||||
TASK_SCHEDULE: 'GCF.KEYBOARD.TASK_SCHEDULE',
|
||||
TASK_SCHEDULE_TODAY: 'GCF.KEYBOARD.TASK_SCHEDULE_TODAY',
|
||||
TASK_SCHEDULE_TOMORROW: 'GCF.KEYBOARD.TASK_SCHEDULE_TOMORROW',
|
||||
TASK_SCHEDULE_NEXT_WEEK: 'GCF.KEYBOARD.TASK_SCHEDULE_NEXT_WEEK',
|
||||
TASK_SCHEDULE_NEXT_MONTH: 'GCF.KEYBOARD.TASK_SCHEDULE_NEXT_MONTH',
|
||||
TASK_SCHEDULE_DEADLINE: 'GCF.KEYBOARD.TASK_SCHEDULE_DEADLINE',
|
||||
TASK_SHORTCUTS: 'GCF.KEYBOARD.TASK_SHORTCUTS',
|
||||
TASK_SHORTCUTS_INFO: 'GCF.KEYBOARD.TASK_SHORTCUTS_INFO',
|
||||
|
|
|
|||
8
src/app/util/get-next-week-day-offset.ts
Normal file
8
src/app/util/get-next-week-day-offset.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { DateAdapter } from '@angular/material/core';
|
||||
|
||||
export const getNextWeekDayOffset = (
|
||||
dateAdapter: DateAdapter<unknown>,
|
||||
date: Date,
|
||||
): number => {
|
||||
return (dateAdapter.getFirstDayOfWeek() - dateAdapter.getDayOfWeek(date) + 7) % 7 || 7;
|
||||
};
|
||||
|
|
@ -2371,7 +2371,6 @@
|
|||
"MOVE_TASK_TO_TOP": "Move task to the top of the list",
|
||||
"MOVE_TASK_UP": "Move task up in the list",
|
||||
"MOVE_TO_BACKLOG": "Move task to project backlog",
|
||||
"MOVE_TO_REGULARS_TASKS": "Move task to Today",
|
||||
"OPEN_PROJECT_NOTES": "Show/hide notes",
|
||||
"PLUGIN_SHORTCUTS": "Plugin Shortcuts",
|
||||
"SELECT_NEXT_TASK": "Select next task",
|
||||
|
|
@ -2387,6 +2386,10 @@
|
|||
"TASK_OPEN_CONTEXT_MENU": "Open task context menu",
|
||||
"TASK_OPEN_ESTIMATION_DIALOG": "Edit estimate/time spent",
|
||||
"TASK_SCHEDULE": "Schedule task",
|
||||
"TASK_SCHEDULE_TODAY": "Schedule task for today",
|
||||
"TASK_SCHEDULE_TOMORROW": "Schedule task for tomorrow",
|
||||
"TASK_SCHEDULE_NEXT_WEEK": "Schedule task for next week",
|
||||
"TASK_SCHEDULE_NEXT_MONTH": "Schedule task for next month",
|
||||
"TASK_SCHEDULE_DEADLINE": "Set task deadline",
|
||||
"TASK_SHORTCUTS": "Tasks",
|
||||
"TASK_SHORTCUTS_INFO": "The following shortcuts apply to the currently selected task (selected via tab or mouse).",
|
||||
|
|
|
|||
|
|
@ -480,7 +480,7 @@
|
|||
"moveTaskToTop": "Ctrl+Alt+ArrowUp",
|
||||
"moveTaskToBottom": "Ctrl+Alt+ArrowDown",
|
||||
"moveToBacklog": "Shift+B",
|
||||
"moveToTodaysTasks": "Shift+T",
|
||||
"taskScheduleToday": "Shift+T",
|
||||
"expandSubTasks": null,
|
||||
"collapseSubTasks": null,
|
||||
"togglePlay": "y",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue