mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
fix(recurring): preserve due date when selecting quick setting (#6766)
Selecting a monthly/yearly quick setting overwrote startDate with today's date because the change callback called getQuickSettingUpdates without a reference date. Now passes the model's current startDate. Also adds dueWithTime fallback in _getReferenceDate().
This commit is contained in:
parent
3a853e5311
commit
da6d70bae5
4 changed files with 234 additions and 2 deletions
192
e2e/tests/recurring/repeat-config-due-date-6766.spec.ts
Normal file
192
e2e/tests/recurring/repeat-config-due-date-6766.spec.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { test, expect } from '../../fixtures/test.fixture';
|
||||
|
||||
/**
|
||||
* Bug: https://github.com/super-productivity/super-productivity/issues/6766
|
||||
*
|
||||
* When creating a recurring task config for a task that has a scheduled due date,
|
||||
* the quick setting dropdown labels show the current date instead of the task's
|
||||
* due date. For example, if a task is scheduled for May 1st, the dropdown shows
|
||||
* "Every year on the 15.03." (today) instead of "Every year on the 1.5." (due date).
|
||||
*/
|
||||
test.describe('Repeat config uses due date for labels (#6766)', () => {
|
||||
test('should show due date in quick setting labels, not current date', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
}) => {
|
||||
await workViewPage.waitForTaskList();
|
||||
|
||||
// Step 1: Create a task
|
||||
const taskTitle = 'Repeat Due Date Test';
|
||||
await workViewPage.addTask(taskTitle);
|
||||
|
||||
const task = taskPage.getTaskByText(taskTitle).first();
|
||||
await expect(task).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Step 2: Open task detail panel
|
||||
await task.hover();
|
||||
const detailBtn = task.locator('.show-additional-info-btn').first();
|
||||
await detailBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await detailBtn.click();
|
||||
await page
|
||||
.locator('task-detail-panel')
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Step 3: Open the schedule dialog via "Planned for" item
|
||||
const planItem = page.locator('task-detail-item').filter({
|
||||
hasText: /Planned for/,
|
||||
});
|
||||
await planItem.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await planItem.click();
|
||||
|
||||
// Step 4: Schedule to the 1st of next month via the calendar
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await dialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Navigate to next month
|
||||
const nextMonthBtn = dialog.getByRole('button', { name: /next month/i });
|
||||
await nextMonthBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Click on day 1 of next month
|
||||
const day1 = dialog
|
||||
.locator('button.mat-calendar-body-cell')
|
||||
.getByText('1', { exact: true })
|
||||
.first();
|
||||
await day1.click();
|
||||
|
||||
// Submit
|
||||
await page.waitForTimeout(300);
|
||||
const scheduleBtn = dialog.getByRole('button', { name: 'Schedule', exact: true });
|
||||
await scheduleBtn.click();
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
|
||||
// Step 5: Task moved to future — navigate to Planner to find it
|
||||
await page.locator('a').filter({ hasText: 'Planner' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Find the task in the planner view
|
||||
const plannerTask = page.locator('planner-task, task').filter({ hasText: taskTitle }).first();
|
||||
await expect(plannerTask).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on it to select and open detail panel
|
||||
await plannerTask.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 6: Open the repeat config dialog via "Recur" item in the detail panel
|
||||
const repeatItem = page.locator('task-detail-item').filter({
|
||||
has: page.locator('mat-icon[svgIcon="repeat"]'),
|
||||
});
|
||||
await repeatItem.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await repeatItem.click();
|
||||
|
||||
// Step 7: Wait for the repeat config dialog
|
||||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await expect(repeatDialog.locator('h1')).toContainText('Add Recurring Task Config');
|
||||
|
||||
// Step 8: Open the "Recurring Config" dropdown
|
||||
const quickSettingSelect = repeatDialog.locator('mat-select').first();
|
||||
await quickSettingSelect.click();
|
||||
|
||||
const options = page.locator('.cdk-overlay-pane mat-option');
|
||||
await options.first().waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Step 9: Verify labels use the scheduled date (1st of next month), not today
|
||||
const today = new Date();
|
||||
const todayDay = today.getDate();
|
||||
|
||||
// The monthly option should say "Every month on the day 1" (the 1st)
|
||||
const monthlyOption = options.filter({ hasText: /Every month/ });
|
||||
const monthlyText = await monthlyOption.textContent();
|
||||
expect(monthlyText).toContain('Every month on the day 1');
|
||||
|
||||
// Today is the 15th — if the bug exists, it would show "day 15" instead of "day 1"
|
||||
if (todayDay !== 1) {
|
||||
expect(monthlyText).not.toContain(`day ${todayDay}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('should preserve due date in labels after selecting a quick setting', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
}) => {
|
||||
await workViewPage.waitForTaskList();
|
||||
|
||||
// Step 1: Create and schedule task for 1st of next month
|
||||
const taskTitle = 'Repeat Select Test';
|
||||
await workViewPage.addTask(taskTitle);
|
||||
|
||||
const task = taskPage.getTaskByText(taskTitle).first();
|
||||
await expect(task).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await task.hover();
|
||||
const detailBtn = task.locator('.show-additional-info-btn').first();
|
||||
await detailBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await detailBtn.click();
|
||||
await page.locator('task-detail-panel').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
const planItem = page.locator('task-detail-item').filter({ hasText: /Planned for/ });
|
||||
await planItem.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await planItem.click();
|
||||
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await dialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await dialog.getByRole('button', { name: /next month/i }).click();
|
||||
await page.waitForTimeout(300);
|
||||
const day1 = dialog.locator('button.mat-calendar-body-cell').getByText('1', { exact: true }).first();
|
||||
await day1.click();
|
||||
await page.waitForTimeout(300);
|
||||
await dialog.getByRole('button', { name: 'Schedule', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
|
||||
// Navigate to planner to find the task
|
||||
await page.locator('a').filter({ hasText: 'Planner' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
const plannerTask = page.locator('planner-task, task').filter({ hasText: taskTitle }).first();
|
||||
await expect(plannerTask).toBeVisible({ timeout: 10000 });
|
||||
await plannerTask.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Open repeat config dialog
|
||||
const repeatItem = page.locator('task-detail-item').filter({
|
||||
has: page.locator('mat-icon[svgIcon="repeat"]'),
|
||||
});
|
||||
await repeatItem.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await repeatItem.click();
|
||||
|
||||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Step 2: Select "Every month on the day 1" from the dropdown
|
||||
const quickSettingSelect = repeatDialog.locator('mat-select').first();
|
||||
await quickSettingSelect.click();
|
||||
|
||||
let options = page.locator('.cdk-overlay-pane mat-option');
|
||||
await options.first().waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Select the monthly option
|
||||
const monthlyOption = options.filter({ hasText: /Every month/ });
|
||||
await monthlyOption.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 3: Re-open dropdown and check labels are STILL using the due date
|
||||
await quickSettingSelect.click();
|
||||
options = page.locator('.cdk-overlay-pane mat-option');
|
||||
await options.first().waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
const monthlyTextAfter = await options.filter({ hasText: /Every month/ }).textContent();
|
||||
const yearlyTextAfter = await options.filter({ hasText: /Every year/ }).textContent();
|
||||
|
||||
// After selecting monthly, the startDate should still reference the 1st
|
||||
// BUG: without the fix in task-repeat-cfg-form.const.ts, selecting a quick setting
|
||||
// overwrites startDate with today's date, causing labels to switch to today
|
||||
const todayDay = new Date().getDate();
|
||||
expect(monthlyTextAfter).toContain('Every month on the day 1');
|
||||
if (todayDay !== 1) {
|
||||
expect(monthlyTextAfter).not.toContain(`day ${todayDay}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -261,6 +261,36 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
expect(yearlyCall!.params.dayAndMonthStr).toBe(expectedDayAndMonthStr);
|
||||
});
|
||||
|
||||
it('should use dueWithTime when task has no dueDay', async () => {
|
||||
// May 1st 2026 at noon local time
|
||||
const may1Noon = new Date(2026, 4, 1, 12, 0, 0).getTime();
|
||||
const taskWithDueWithTime = {
|
||||
...mockTask,
|
||||
dueDay: undefined,
|
||||
dueWithTime: may1Noon,
|
||||
} as unknown as TaskCopy;
|
||||
|
||||
const fixture = await setupTestBed({ task: taskWithDueWithTime });
|
||||
const translateService = TestBed.inject(TranslateService);
|
||||
const instantCalls: { key: string; params: any }[] = [];
|
||||
spyOn(translateService, 'instant').and.callFake((key: any, params?: any) => {
|
||||
instantCalls.push({ key, params });
|
||||
return key;
|
||||
});
|
||||
|
||||
(fixture.componentInstance as any)._initializeFormConfig();
|
||||
|
||||
const monthlyCall = instantCalls.find(
|
||||
(c) => c.key === T.F.TASK_REPEAT.F.Q_MONTHLY_CURRENT_DATE,
|
||||
);
|
||||
|
||||
const dueDate = new Date(may1Noon);
|
||||
const expectedDayStr = dueDate.toLocaleDateString('en-US', { day: 'numeric' });
|
||||
|
||||
expect(monthlyCall).toBeDefined();
|
||||
expect(monthlyCall!.params.dateDayStr).toBe(expectedDayStr);
|
||||
});
|
||||
|
||||
it('should pass today day/month to translate when task has no due date', async () => {
|
||||
const taskNoDueDate = {
|
||||
...mockTask,
|
||||
|
|
|
|||
|
|
@ -375,8 +375,12 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
}
|
||||
|
||||
private _getReferenceDate(): Date {
|
||||
if (this._data.task?.dueDay) {
|
||||
return dateStrToUtcDate(this._data.task.dueDay);
|
||||
const task = this._data.task;
|
||||
if (task?.dueDay) {
|
||||
return dateStrToUtcDate(task.dueDay);
|
||||
}
|
||||
if (task?.dueWithTime) {
|
||||
return new Date(task.dueWithTime);
|
||||
}
|
||||
if (this._data.repeatCfg?.startDate) {
|
||||
return dateStrToUtcDate(this._data.repeatCfg.startDate);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { T } from '../../../t.const';
|
|||
import { isValidSplitTime } from '../../../util/is-valid-split-time';
|
||||
import { TASK_REMINDER_OPTIONS } from '../../planner/dialog-schedule-task/task-reminder-options.const';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
import { RepeatQuickSetting, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { getQuickSettingUpdates } from './get-quick-setting-updates';
|
||||
import { TaskReminderOptionId } from '../../tasks/task.model';
|
||||
|
|
@ -36,8 +37,13 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
|
|||
// NOTE replaced in component to allow for dynamic translation
|
||||
options: [],
|
||||
change: (field, event) => {
|
||||
const currentStartDate = field.model?.startDate;
|
||||
const referenceDate = currentStartDate
|
||||
? dateStrToUtcDate(currentStartDate)
|
||||
: undefined;
|
||||
const updatesForQuickSetting = getQuickSettingUpdates(
|
||||
event.value as RepeatQuickSetting,
|
||||
referenceDate,
|
||||
);
|
||||
if (updatesForQuickSetting) {
|
||||
// NOTE: for some reason this doesn't update the model value, just the view value :(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue