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:
Johannes Millan 2026-03-15 14:46:48 +01:00
parent 3a853e5311
commit da6d70bae5
4 changed files with 234 additions and 2 deletions

View 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}`);
}
});
});

View file

@ -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,

View file

@ -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);

View file

@ -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 :(