diff --git a/docs/wiki/2.06-Manage-Repeating-Tasks.md b/docs/wiki/2.06-Manage-Repeating-Tasks.md index b361f58c82..5c7594a2ef 100755 --- a/docs/wiki/2.06-Manage-Repeating-Tasks.md +++ b/docs/wiki/2.06-Manage-Repeating-Tasks.md @@ -4,7 +4,7 @@ For how repeat configuration works and when instances are created, see [[4.13-Re ## Open the Recurring Task Dialog (add or edit) -**From a task:** Open the task (click it so the task detail panel opens on the right). In the panel, find the **Repeat** row (repeat icon and "Repeat" label). Click the **add** (plus) or **edit** (pencil) control on that row. The dialog opens with the title **Add Recurring Task Config** (new) or **Edit Recurring Task Config** (existing). Only main tasks (not subtasks) and non–issue-linked tasks show the Repeat row; tasks imported from Jira or Git cannot be recurring. +**From a task:** Open the task (click it so the task detail panel opens on the right), select **Schedule**, then select **Repeat** in the Schedule dialog. The recurring-task dialog opens with the title **Add Recurring Task Config** (new) or **Edit Recurring Task Config** (existing). Repeat is available only for main tasks (not subtasks) that are not linked to an issue; tasks imported from Jira or Git cannot be recurring. **From the scheduled list:** Go to the **Scheduled tasks** page (e.g. via **Shift+S** or the **Go to scheduled tasks** shortcut). In the **Recurring Tasks** section, each recurring task is shown as a card. Click the card (the line with the repeat icon and the recurrence text) to open the **Edit Recurring Task Config** dialog for that configuration. @@ -12,7 +12,7 @@ You can also open the dialog from a repeating task instance or from a repeat pro ## Create a New Repeating Task -1. Open a **main task** (not a subtask, not from an issue) and open the **Repeat** row in the task detail panel (see above). The dialog title is **Add Recurring Task Config**. +1. Open a **main task** (not a subtask, not from an issue), select **Schedule** in the task detail panel, then select **Repeat**. The dialog title is **Add Recurring Task Config**. 2. Enter the **Task title** (or keep the task’s current title, which is often pre-filled). 3. In **Recurring Config**, choose a quick setting: - Every day @@ -36,7 +36,7 @@ You can also open the dialog from a repeating task instance or from a repeat pro ## Add a time and Reminder to a Repeating Task -1. Open the recurring task config (task detail panel → Repeat, or scheduled list → click the recurring task card). +1. Open the recurring task config (task detail panel → **Schedule** → **Repeat**, or scheduled list → click the recurring task card). 2. Set **Scheduled start time** (e.g. 15:00). Leave it blank for an all-day task. 3. When a time is set, **Remind at** appears. Choose an option (e.g. when it starts, 5 minutes before it starts). 4. Click **Save**. @@ -58,7 +58,7 @@ In the dialog, use **Tags to add** to add one or more tags. New instances will h ## Edit a Recurring Task Configuration -Open the config (from the task’s **Repeat** row in the detail panel, or from the **Recurring Tasks** section on the scheduled list by clicking the card). Change **Task title**, **Recurring Config**, **Start date**, weekdays, **Scheduled start time**, **Remind at**, **Default Estimate**, **Order**, **Default notes**, tags, or inherit options. Click **Save**. +Open the config (task detail panel → **Schedule** → **Repeat**, or from the **Recurring Tasks** section on the scheduled list by clicking the card). Change **Task title**, **Recurring Config**, **Start date**, weekdays, **Scheduled start time**, **Remind at**, **Default Estimate**, **Order**, **Default notes**, tags, or inherit options. Click **Save**. ## Delete a Single Instance (e.g. “today only”) diff --git a/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts b/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts index 062bd5df40..ddcfb67e92 100644 --- a/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts +++ b/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts @@ -1,4 +1,9 @@ import { expect, test } from '../../fixtures/test.fixture'; +import { + openRecurDialog, + openRecurScheduleDialog, + saveRecurDialog, +} from '../../utils/recurring-task-helpers'; /** * Bug: https://github.com/super-productivity/super-productivity/issues/7067 @@ -31,20 +36,10 @@ test('should not crash when a repeat config has an invalid startTime in the stor await expect(task).toBeVisible({ timeout: 10000 }); await taskPage.openTaskDetail(task); - await page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }) - .click(); - - const repeatDialog = page.locator('mat-dialog-container').first(); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); + await openRecurDialog(page); // Set a valid startTime so the config has startTime + remindAt in the store - await repeatDialog.locator('.planned-start-date-btn').click(); - const scheduleDialog = page - .locator('mat-dialog-container') - .filter({ has: page.locator('datetime-picker') }); - await expect(scheduleDialog).toBeVisible(); + const scheduleDialog = await openRecurScheduleDialog(page); const startTimeField = scheduleDialog.getByLabel('Time'); await expect(startTimeField).toBeVisible({ timeout: 5000 }); @@ -56,10 +51,7 @@ test('should not crash when a repeat config has an invalid startTime in the stor await scheduleDialog.waitFor({ state: 'hidden' }); await page.waitForTimeout(300); - const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 }); + await saveRecurDialog(page); await page.keyboard.press('Escape'); // Wait for the op to be flushed to IndexedDB before we try to read it await page.waitForTimeout(1500); diff --git a/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts b/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts index a0149f00b5..265981723e 100644 --- a/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts +++ b/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts @@ -1,4 +1,9 @@ import { expect, test } from '../../fixtures/test.fixture'; +import { + openRecurDialog, + openRecurScheduleDialog, + saveRecurDialog, +} from '../../utils/recurring-task-helpers'; /** * Bug: https://github.com/super-productivity/super-productivity/issues/6856 @@ -34,26 +39,9 @@ test.describe('Recurring Task - Future Start Date (#6856)', () => { await expect(detailBtn).toBeVisible({ timeout: 5000 }); await detailBtn.click(); - const recurItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); - // 3. Wait for the repeat dialog and set a future start date via calendar - const repeatDialog = page.locator('mat-dialog-container').first(); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); - - // Open the schedule dialog - const scheduleBtn = repeatDialog.locator('.planned-start-date-btn'); - await expect(scheduleBtn).toBeVisible({ timeout: 5000 }); - await scheduleBtn.click(); - - // Wait for the schedule dialog to appear - const scheduleDialog = page - .locator('mat-dialog-container') - .filter({ has: page.locator('datetime-picker') }); - await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 }); + await openRecurDialog(page); + const scheduleDialog = await openRecurScheduleDialog(page); const calendar = scheduleDialog.locator('mat-calendar'); await expect(calendar).toBeVisible({ timeout: 5000 }); @@ -77,10 +65,7 @@ test.describe('Recurring Task - Future Start Date (#6856)', () => { await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 }); // Save the repeat config — wait for the button to be enabled first - const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 }); + await saveRecurDialog(page); // 4. Assert in-session first: the task should disappear from Today as soon // as the side-effect actions (updateTask + planTaskForDay) settle. This is diff --git a/e2e/tests/recurring/recurring-missed-day-bug-4559.spec.ts b/e2e/tests/recurring/recurring-missed-day-bug-4559.spec.ts index 3c2deee02e..2b3996ac05 100644 --- a/e2e/tests/recurring/recurring-missed-day-bug-4559.spec.ts +++ b/e2e/tests/recurring/recurring-missed-day-bug-4559.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '../../fixtures/test.fixture'; +import { openRecurDialog, saveRecurDialog } from '../../utils/recurring-task-helpers'; /** * Bug: https://github.com/super-productivity/super-productivity/issues/4559 @@ -47,17 +48,10 @@ test.describe('Recurring task - missed scheduled day (#4559)', () => { await expect(task).toBeVisible({ timeout: 10000 }); await taskPage.openTaskDetail(task); - const recurItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); - // 3. Pick the "Every month on the first day" quick setting and save. // The regex avoids "first Monday" (Q_MONTHLY_NTH_WEEKDAY) — only // Q_MONTHLY_FIRST_DAY contains the literal "first day". - const repeatDialog = page.locator('mat-dialog-container'); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); + const repeatDialog = await openRecurDialog(page); const quickSettingSelect = repeatDialog.locator('mat-select').first(); await quickSettingSelect.click(); @@ -65,10 +59,7 @@ test.describe('Recurring task - missed scheduled day (#4559)', () => { await expect(firstDayOption).toBeVisible({ timeout: 5000 }); await firstDayOption.click(); - const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 }); + await saveRecurDialog(page); await page.keyboard.press('Escape'); // 4. Mark the Jun 1 instance done so the cfg's lastTaskCreationDay is diff --git a/e2e/tests/recurring/recurring-preserve-past-due-day-bug-7344.spec.ts b/e2e/tests/recurring/recurring-preserve-past-due-day-bug-7344.spec.ts index 466fbb49c1..66e458c2ed 100644 --- a/e2e/tests/recurring/recurring-preserve-past-due-day-bug-7344.spec.ts +++ b/e2e/tests/recurring/recurring-preserve-past-due-day-bug-7344.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '../../fixtures/test.fixture'; +import { openRecurDialog, saveRecurDialog } from '../../utils/recurring-task-helpers'; import { waitForStatePersistence } from '../../utils/waits'; /** @@ -55,14 +56,7 @@ test.describe('Recurring Task - preserve past dueDay (#7344)', () => { // 3. Open the repeat dialog via the task detail panel. const reopenedTask = taskPage.getTaskByText(taskTitle).first(); await taskPage.openTaskDetail(reopenedTask); - const recurItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); - - const repeatDialog = page.locator('mat-dialog-container'); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); + const repeatDialog = await openRecurDialog(page); // 4. Switch to CUSTOM so repeatCycle becomes editable without overriding // startDate (picking YEARLY_CURRENT_DATE would reset startDate to today). @@ -79,10 +73,7 @@ test.describe('Recurring Task - preserve past dueDay (#7344)', () => { await yearlyOption.click(); // 6. Save the repeat config. - const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 }); + await saveRecurDialog(page); await page.keyboard.press('Escape'); // 7. ASSERTION: after reload, the task remains visible in Today view. diff --git a/e2e/tests/recurring/recurring-skip-and-remove-persistence.spec.ts b/e2e/tests/recurring/recurring-skip-and-remove-persistence.spec.ts index 25ba17d536..1006227ae8 100644 --- a/e2e/tests/recurring/recurring-skip-and-remove-persistence.spec.ts +++ b/e2e/tests/recurring/recurring-skip-and-remove-persistence.spec.ts @@ -140,10 +140,8 @@ test.describe('Recurring task occurrence and series removal persistence', () => await gotoHashRoute(page, '/#/tag/TODAY/tasks', task); await taskPage.openTaskDetail(task); - const repeatItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(repeatItem.locator('.reminder-value')).toHaveText('Every day'); + const repeatValue = page.locator('task-detail-item .schedule-value__repeat'); + await expect(repeatValue).toContainText('Every day'); const repeatDialog = await openRecurDialog(page); await repeatDialog.getByRole('button', { name: 'Remove', exact: true }).click(); @@ -157,7 +155,7 @@ test.describe('Recurring task occurrence and series removal persistence', () => await expect(removeConfirmation).toBeHidden({ timeout: 10000 }); await expect(task).toBeVisible(); - await expect(repeatItem.locator('.reminder-value')).toHaveCount(0, { + await expect(repeatValue).toHaveCount(0, { timeout: 10000, }); await page.keyboard.press('Escape'); diff --git a/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts b/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts index 224fc25f7b..233b04c85e 100644 --- a/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts +++ b/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts @@ -1,5 +1,10 @@ import { expect, test } from '../../fixtures/test.fixture'; -import { saveRecurDialog, setRecurStartDate } from '../../utils/recurring-task-helpers'; +import { + openRecurDialog, + openRecurScheduleDialog, + saveRecurDialog, + setRecurStartDate, +} from '../../utils/recurring-task-helpers'; /** * Bug: https://github.com/super-productivity/super-productivity/issues/6860 @@ -41,26 +46,10 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => { await expect(detailBtn).toBeVisible({ timeout: 5000 }); await detailBtn.click(); - const recurItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); - - // 3. Wait for the repeat dialog to appear - const repeatDialog = page.locator('mat-dialog-container').first(); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); + const repeatDialog = await openRecurDialog(page); // 4. Open the schedule dialog - const scheduleBtn = repeatDialog.locator('.planned-start-date-btn'); - await expect(scheduleBtn).toBeVisible({ timeout: 5000 }); - await scheduleBtn.click(); - - // Wait for the schedule dialog to appear - const scheduleDialog = page - .locator('mat-dialog-container') - .filter({ has: page.locator('datetime-picker') }); - await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 }); + const scheduleDialog = await openRecurScheduleDialog(page); const calendar = scheduleDialog.locator('mat-calendar'); await expect(calendar).toBeVisible({ timeout: 5000 }); @@ -90,10 +79,7 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => { expect(valText).not.toContain('1970'); // 6. Save and verify the date survives persistence - const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 }); + await saveRecurDialog(page); }); test('should preserve start date when configuring recurring task via helper', async ({ page, @@ -122,15 +108,7 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => { await expect(detailBtn).toBeVisible({ timeout: 5000 }); await detailBtn.click(); - const recurItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); - - // 3. Wait for the repeat dialog to appear - const repeatDialog = page.locator('mat-dialog-container').first(); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); + await openRecurDialog(page); await setRecurStartDate(page, '15/06/2026'); await saveRecurDialog(page); diff --git a/e2e/tests/recurring/repeat-task-day-change-bug-6230.spec.ts b/e2e/tests/recurring/repeat-task-day-change-bug-6230.spec.ts index 2f5bcd442d..1c53c7196e 100644 --- a/e2e/tests/recurring/repeat-task-day-change-bug-6230.spec.ts +++ b/e2e/tests/recurring/repeat-task-day-change-bug-6230.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '../../fixtures/test.fixture'; +import { openRecurDialog, saveRecurDialog } from '../../utils/recurring-task-helpers'; /** * Bug: https://github.com/super-productivity/super-productivity/issues/6230 @@ -44,19 +45,9 @@ test.describe('Repeat Task - Day Change (#6230)', () => { // 3. Open task detail and click the repeat/recurrence icon await taskPage.openTaskDetail(task); - const recurItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); - // 4. Save the repeat dialog with default settings (DAILY) - const repeatDialog = page.locator('mat-dialog-container'); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); - const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 }); + await openRecurDialog(page); + await saveRecurDialog(page); // Close the task detail panel await page.keyboard.press('Escape'); diff --git a/e2e/tests/recurring/repeat-task-first-occurrence-bug-5594.spec.ts b/e2e/tests/recurring/repeat-task-first-occurrence-bug-5594.spec.ts index 23f0021b88..179085641f 100644 --- a/e2e/tests/recurring/repeat-task-first-occurrence-bug-5594.spec.ts +++ b/e2e/tests/recurring/repeat-task-first-occurrence-bug-5594.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '../../fixtures/test.fixture'; +import { openRecurDialog, saveRecurDialog } from '../../utils/recurring-task-helpers'; /** * Bug: https://github.com/super-productivity/super-productivity/issues/5594 @@ -32,15 +33,8 @@ test.describe('Issue #5594: First repeat occurrence should not always be today', // 3. Open task detail and click the repeat/recurrence icon await taskPage.openTaskDetail(task); - const recurItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); - // 4. Wait for repeat dialog - const repeatDialog = page.locator('mat-dialog-container'); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); + const repeatDialog = await openRecurDialog(page); // 5. Change quickSetting to CUSTOM const quickSettingSelect = repeatDialog.locator('mat-select').first(); @@ -84,10 +78,7 @@ test.describe('Issue #5594: First repeat occurrence should not always be today', } // 8. Save the repeat config - const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 }); + await saveRecurDialog(page); // Close task detail panel await page.keyboard.press('Escape'); @@ -140,19 +131,9 @@ test.describe('Issue #5594: First repeat occurrence should not always be today', // 3. Open repeat dialog await taskPage.openTaskDetail(task); - const recurItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); - // 4. Save with default DAILY settings - const repeatDialog = page.locator('mat-dialog-container'); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); - const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 }); + await openRecurDialog(page); + await saveRecurDialog(page); // Close task detail panel await page.keyboard.press('Escape'); diff --git a/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts b/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts index bb3363d239..844176dd60 100644 --- a/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts +++ b/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts @@ -1,4 +1,9 @@ import { expect, test } from '../../fixtures/test.fixture'; +import { + openRecurDialog, + openRecurScheduleDialog, + saveRecurDialog, +} from '../../utils/recurring-task-helpers'; /** * Regression guard: a TIMED daily repeat task must get its new-day instance @@ -40,26 +45,11 @@ test.describe('Repeat Task - Timed + Cold Reopen Day Change', () => { // 3. Open task detail → open the repeat/recurrence dialog. await taskPage.openTaskDetail(task); - const recurItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); - - const repeatDialog = page.locator('mat-dialog-container').first(); - await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); + await openRecurDialog(page); // 4. Make it a TIMED daily repeat: Open schedule dialog and set a start time (13:00). // remindAt defaults to AtStart once startTime is set, so the config is timed. - const scheduleBtn = repeatDialog.locator('.planned-start-date-btn'); - await expect(scheduleBtn).toBeVisible({ timeout: 5000 }); - await scheduleBtn.click(); - - // Wait for the schedule dialog to appear - const scheduleDialog = page - .locator('mat-dialog-container') - .filter({ has: page.locator('datetime-picker') }); - await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 }); + const scheduleDialog = await openRecurScheduleDialog(page); // Set a valid startTime const startTimeField = scheduleDialog.getByLabel('Time'); @@ -75,10 +65,7 @@ test.describe('Repeat Task - Timed + Cold Reopen Day Change', () => { await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 }); // 5. Save the (default DAILY) repeat config. - const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); - await expect(saveBtn).toBeEnabled({ timeout: 5000 }); - await saveBtn.click(); - await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 }); + await saveRecurDialog(page); await page.keyboard.press('Escape'); // 6. Day X instance exists in Today (scheduled 13:00, still in the future). diff --git a/e2e/utils/recurring-task-helpers.ts b/e2e/utils/recurring-task-helpers.ts index bbb97b04c9..21d1e4b82e 100644 --- a/e2e/utils/recurring-task-helpers.ts +++ b/e2e/utils/recurring-task-helpers.ts @@ -10,15 +10,26 @@ import { expect, Locator, Page } from '@playwright/test'; * every spec that uses them. */ -const DIALOG_CONTAINER = 'mat-dialog-container'; +const DIALOG_CONTAINER = 'mat-dialog-container:has(dialog-edit-task-repeat-cfg)'; +const SCHEDULE_DIALOG = 'mat-dialog-container:has(dialog-schedule-task)'; -/** Open the recurring-config dialog from the task detail panel's repeat row. */ +/** Open the recurring-config dialog via the task detail panel's schedule row. */ export const openRecurDialog = async (page: Page): Promise => { - const recurItem = page + const scheduleItem = page .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) }); - await expect(recurItem).toBeVisible({ timeout: 5000 }); - await recurItem.click(); + .filter({ + has: page.locator('mat-icon', { + hasText: /^(alarm|today|schedule|repeat)$/, + }), + }) + .first(); + await expect(scheduleItem).toBeVisible({ timeout: 5000 }); + await scheduleItem.click(); + + const scheduleDialog = page.locator(SCHEDULE_DIALOG); + await scheduleDialog.waitFor({ state: 'visible', timeout: 10000 }); + await scheduleDialog.locator('.repeat-btn').click(); + const dialog = page.locator(DIALOG_CONTAINER); await dialog.waitFor({ state: 'visible', timeout: 10000 }); return dialog; @@ -45,17 +56,27 @@ export const openRecurDialogFromProjection = async ( }; /** - * Set the recurring "Start date" by using the calendar datepicker. + * Open the schedule dialog nested inside the recurring-config dialog. */ -export const setRecurStartDate = async (page: Page, ddmmyyyy: string): Promise => { +export const openRecurScheduleDialog = async (page: Page): Promise => { const dialog = page.locator(DIALOG_CONTAINER).first(); const scheduleBtn = dialog.locator('.planned-start-date-btn'); await expect(scheduleBtn).toBeVisible({ timeout: 5000 }); + const scheduleDialogs = page.locator(SCHEDULE_DIALOG); + const previousScheduleDialogCount = await scheduleDialogs.count(); await scheduleBtn.click(); - const scheduleDialog = page - .locator(DIALOG_CONTAINER) - .filter({ has: page.locator('datetime-picker') }); + await expect(scheduleDialogs).toHaveCount(previousScheduleDialogCount + 1, { + timeout: 5000, + }); + return scheduleDialogs.nth(previousScheduleDialogCount); +}; + +/** + * Set the recurring "Start date" by using the calendar datepicker. + */ +export const setRecurStartDate = async (page: Page, ddmmyyyy: string): Promise => { + const scheduleDialog = await openRecurScheduleDialog(page); await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 }); const calendar = scheduleDialog.locator('mat-calendar'); @@ -123,6 +144,12 @@ export const saveRecurDialog = async (page: Page): Promise => { await expect(saveBtn).toBeEnabled({ timeout: 5000 }); await saveBtn.click(); await dialog.waitFor({ state: 'hidden', timeout: 10000 }); + + const scheduleDialog = page.locator(SCHEDULE_DIALOG); + if (await scheduleDialog.isVisible()) { + await scheduleDialog.locator('[data-test-id="schedule-cancel-btn"]').click(); + await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 }); + } }; /** diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html index 30e252f9aa..bf37a17db7 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html @@ -16,6 +16,25 @@ > } + @if (canRepeat) { + @let repeatLabel = repeatCfgLabel(); + + } +
@if ( selectedTime && diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.invalid-time.spec.ts b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.invalid-time.spec.ts index 37d787b1ae..d859dfa99e 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.invalid-time.spec.ts +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.invalid-time.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -61,7 +62,11 @@ describe('DialogScheduleTaskComponent — malformed selectedTime', () => { beforeEach(async () => { matDialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['close']); - taskServiceSpy = jasmine.createSpyObj('TaskService', ['scheduleTask']); + taskServiceSpy = jasmine.createSpyObj('TaskService', [ + 'scheduleTask', + 'getByIdLive$', + ]); + taskServiceSpy.getByIdLive$.and.returnValue(of(task)); // ngAfterViewInit reads viewChild.required(MatCalendar). With the template // stubbed there is no calendar — skip the hook and test in isolation. spyOn(DialogScheduleTaskComponent.prototype, 'ngAfterViewInit').and.stub(); diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss index 5f988437bc..de1ab356de 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss @@ -48,6 +48,20 @@ mat-dialog-actions { white-space: nowrap; } +.repeat-btn { + width: calc(100% - var(--s2) * 2); + margin: 0 var(--s2) var(--s2); +} + +.repeat-value { + font-weight: bold; + + &--empty { + color: var(--text-color-muted); + font-weight: normal; + } +} + .schedule-info { display: flex; align-items: flex-start; diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts index fb5cfe8509..f92701c09d 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts @@ -9,6 +9,7 @@ import { } from '@angular/core'; import { MAT_DIALOG_DATA, + MatDialog, MatDialogActions, MatDialogContent, MatDialogRef, @@ -49,6 +50,12 @@ import { selectTimelineConfig } from '../../config/store/global-config.reducer'; import { getTimeConflictTaskIds } from '../../tasks/util/get-time-conflict-task-ids'; import { isTaskOutsideWorkHours } from '../../tasks/util/is-task-outside-work-hours'; import { DateTimePickerComponent } from '../../../ui/datetime-picker/datetime-picker.component'; +import { Observable, of } from 'rxjs'; +import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { selectTaskRepeatCfgByIdAllowUndefined } from '../../task-repeat-cfg/store/task-repeat-cfg.selectors'; +import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service'; +import { getTaskRepeatInfoText } from '../../tasks/task-detail-panel/get-task-repeat-info-text.util'; @Component({ selector: 'dialog-schedule-task', @@ -76,6 +83,8 @@ export class DialogScheduleTaskComponent implements AfterViewInit { isSubmitOnQuickAccess?: boolean; }>(MAT_DIALOG_DATA); private _matDialogRef = inject>(MatDialogRef); + private _matDialog = inject(MatDialog); + private _dateTimeFormatService = inject(DateTimeFormatService); private _cd = inject(ChangeDetectorRef); private _store = inject(Store); private _snackService = inject(SnackService); @@ -103,6 +112,49 @@ export class DialogScheduleTaskComponent implements AfterViewInit { remindAvailableOptions: TaskReminderOption[] = TASK_REMINDER_OPTIONS; task: TaskCopy | undefined = this.data.task; + // Recurrence only applies to top-level, non-issue tasks (mirrors the panel's + // Repeat row gating). Hidden in select-due-only mode, since that mode IS the + // repeat dialog's own start-date picker and a repeat button there is circular. + readonly canRepeat = + !this.data.isSelectDueOnly && + !!this.data.task && + !this.data.task.parentId && + !this.data.task.issueId; + + // Reactive label for the repeat button. Tracks the live task in the store so it + // flips from "does not repeat" to e.g. "Daily" the moment the repeat sub-dialog + // saves, without closing this dialog. + private _task$: Observable = this.data.task + ? this._taskService.getByIdLive$(this.data.task.id) + : of(null); + // Live task from the store — used to open the repeat dialog with an up-to-date + // repeatCfgId (the injected data.task is a frozen snapshot from dialog open). + private _liveTask = toSignal(this._task$); + readonly repeatCfgLabel = toSignal( + this._task$.pipe( + map((task) => task?.repeatCfgId ?? null), + distinctUntilChanged(), + switchMap((repeatCfgId) => + repeatCfgId + ? this._store.select(selectTaskRepeatCfgByIdAllowUndefined, { id: repeatCfgId }) + : of(null), + ), + map((repeatCfg) => { + if (!repeatCfg) { + return null; + } + const [key, params] = getTaskRepeatInfoText( + repeatCfg, + this._dateTimeFormatService.currentLocale(), + this._dateTimeFormatService, + this._translateService, + ); + return this._translateService.instant(key, params); + }), + ), + { initialValue: null }, + ); + private _selectedDate = signal(null); private _selectedTime = signal(null); selectedReminderCfgId!: TaskReminderOptionId; @@ -113,6 +165,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit { // private _prevSelectedQuickAccessDate: Date | null = null; // private _prevQuickAccessAction: number | null = null; private _previewTaskId = '__schedule-preview__'; + private _repeatCfgCreatedInDialogId: string | null = null; private _defaultTaskRemindCfgId = computed( () => @@ -254,6 +307,45 @@ export class DialogScheduleTaskComponent implements AfterViewInit { this.selectedDate = new Date(newDate); } + async openRepeatDialog(): Promise { + // Use the live task, not the frozen dialog snapshot: if a repeat cfg was just + // created here, re-opening must route to edit (via its repeatCfgId) instead of + // creating a second, orphaning cfg. + const task = this._liveTask() ?? this.data.task; + if (!task) { + return; + } + // Lazy import to avoid a static schedule-dialog <-> repeat-dialog module cycle, + // matching how task.component / add-task-bar open this dialog. + const { DialogEditTaskRepeatCfgComponent } = + await import('../../task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component'); + const selectedDate = this.selectedDate; + const targetDate = selectedDate + ? typeof selectedDate === 'string' + ? selectedDate + : getDbDateStr(selectedDate) + : task.dueDay || getDbDateStr(task.dueWithTime || task.created); + this._matDialog + .open(DialogEditTaskRepeatCfgComponent, { + restoreFocus: true, + data: { + task, + // Only the exact config created inside this still-open schedule dialog + // can safely skip the destructive removal warning. + isRemoveConfirmationRequired: + task.repeatCfgId !== this._repeatCfgCreatedInDialogId, + initialStartDate: targetDate, + targetDate, + }, + }) + .afterClosed() + .subscribe((createdRepeatCfgId: unknown) => { + if (typeof createdRepeatCfgId === 'string') { + this._repeatCfgCreatedInDialogId = createdRepeatCfgId; + } + }); + } + remove(): void { // Only handle remove if task is provided if (!this.data.task) { diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.repeat.spec.ts b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.repeat.spec.ts new file mode 100644 index 0000000000..355b867548 --- /dev/null +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.repeat.spec.ts @@ -0,0 +1,245 @@ +import { TestBed } from '@angular/core/testing'; +import { DialogScheduleTaskComponent } from './dialog-schedule-task.component'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService, TranslateStore } from '@ngx-translate/core'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { SnackService } from '../../../core/snack/snack.service'; +import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe'; +import { TaskService } from '../../tasks/task.service'; +import { ReminderService } from '../../reminder/reminder.service'; +import { DateService } from '../../../core/date/date.service'; +import { GlobalConfigService } from '../../config/global-config.service'; +import { DateAdapter } from '@angular/material/core'; +import { TaskCopy } from '../../tasks/task.model'; +import { + selectAllTasksWithDueTimeSorted, + selectTaskById, +} from '../../tasks/store/task.selectors'; +import { selectTimelineConfig } from '../../config/store/global-config.reducer'; +import { selectTaskRepeatCfgByIdAllowUndefined } from '../../task-repeat-cfg/store/task-repeat-cfg.selectors'; +import { getDbDateStr } from '../../../util/get-db-date-str'; + +/** + * Covers the recurrence surface added to the schedule dialog: the `canRepeat` + * gating (including the isSelectDueOnly circular-picker guard) and `openRepeatDialog` + * seeding from the LIVE task (regression guard against creating a duplicate cfg when + * re-opened after a repeat was just added). + */ +describe('DialogScheduleTaskComponent — repeat button', () => { + let matDialogSpy: jasmine.SpyObj; + + const baseTask = (partial: Partial = {}): TaskCopy => + ({ + id: 'task-1', + title: 'T', + tagIds: [], + projectId: 'DEFAULT', + timeSpentOnDay: {}, + attachments: [], + timeEstimate: 0, + timeSpent: 0, + isDone: false, + created: 1640995200000, + subTaskIds: [], + ...partial, + }) as TaskCopy; + + const setup = async ( + data: Record, + opts: { + liveTask?: TaskCopy; + liveTask$?: Observable; + repeatDialogResult?: string; + } = {}, + ): Promise => { + const task = data['task'] as TaskCopy | undefined; + const liveTask = opts.liveTask ?? task ?? null; + const taskServiceSpy = jasmine.createSpyObj('TaskService', [ + 'scheduleTask', + 'getByIdLive$', + ]); + taskServiceSpy.getByIdLive$.and.returnValue(opts.liveTask$ ?? of(liveTask)); + matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']); + matDialogSpy.open.and.returnValue({ + afterClosed: () => of(opts.repeatDialogResult), + } as MatDialogRef); + spyOn(DialogScheduleTaskComponent.prototype, 'ngAfterViewInit').and.stub(); + + await TestBed.configureTestingModule({ + imports: [ + DialogScheduleTaskComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + provideMockStore({ + initialState: {}, + selectors: [ + { selector: selectAllTasksWithDueTimeSorted, value: [] }, + { selector: selectTimelineConfig, value: null }, + { selector: selectTaskById, value: liveTask }, + { selector: selectTaskRepeatCfgByIdAllowUndefined, value: undefined }, + ], + }), + { + provide: MatDialogRef, + useValue: jasmine.createSpyObj('MatDialogRef', ['close']), + }, + { provide: MatDialog, useValue: matDialogSpy }, + { provide: MAT_DIALOG_DATA, useValue: data }, + { + provide: SnackService, + useValue: jasmine.createSpyObj('SnackService', ['open']), + }, + { provide: TaskService, useValue: taskServiceSpy }, + { + provide: ReminderService, + useValue: jasmine.createSpyObj('ReminderService', ['getById']), + }, + { + provide: DateService, + useValue: { + isToday: () => false, + todayStr: () => '2026-05-26', + getStartOfNextDayDiffMs: () => 0, + }, + }, + { + provide: GlobalConfigService, + useValue: { localization: () => undefined, cfg: () => undefined }, + }, + { + provide: DateAdapter, + useValue: { getFirstDayOfWeek: () => 1, getDayOfWeek: () => 1 }, + }, + TranslateService, + TranslateStore, + LocaleDatePipe, + ], + }) + .overrideComponent(DialogScheduleTaskComponent, { set: { template: '' } }) + .compileComponents(); + + return TestBed.createComponent(DialogScheduleTaskComponent).componentInstance; + }; + + afterEach(() => { + TestBed.inject(MockStore).resetSelectors(); + TestBed.resetTestingModule(); + }); + + describe('canRepeat gating', () => { + it('is true for a top-level, non-issue task', async () => { + const c = await setup({ task: baseTask() }); + expect(c.canRepeat).toBe(true); + }); + + it('is false for a subtask', async () => { + const c = await setup({ task: baseTask({ parentId: 'parent-1' }) }); + expect(c.canRepeat).toBe(false); + }); + + it('is false for an issue task', async () => { + const c = await setup({ task: baseTask({ issueId: 'ISSUE-1' }) }); + expect(c.canRepeat).toBe(false); + }); + + it('is false in isSelectDueOnly mode (avoids the circular repeat picker)', async () => { + const c = await setup({ task: baseTask(), isSelectDueOnly: true }); + expect(c.canRepeat).toBe(false); + }); + + it('is false when there is no task', async () => { + const c = await setup({ targetDay: '2026-05-26' }); + expect(c.canRepeat).toBe(false); + }); + }); + + describe('repeatCfgLabel', () => { + it('is null when the task has no repeat cfg', async () => { + const c = await setup({ task: baseTask() }); + expect(c.repeatCfgLabel()).toBeNull(); + }); + }); + + describe('openRepeatDialog', () => { + it('keeps confirmation for a config that appeared in live state', async () => { + const staleSnapshot = baseTask(); + const liveTask = baseTask({ repeatCfgId: 'cfg-1' }); + const c = await setup({ task: staleSnapshot }, { liveTask }); + + await c.openRepeatDialog(); + + expect(matDialogSpy.open).toHaveBeenCalledTimes(1); + const dialogArg = matDialogSpy.open.calls.mostRecent().args[1] as { + data: { + task: TaskCopy; + targetDate: string; + initialStartDate: string; + isRemoveConfirmationRequired: boolean; + }; + }; + expect(dialogArg.data.task).toBe(liveTask); + expect(dialogArg.data.task.repeatCfgId).toBe('cfg-1'); + expect(dialogArg.data.targetDate).toBe(getDbDateStr(new Date(liveTask.created))); + expect(dialogArg.data.isRemoveConfirmationRequired).toBeTrue(); + }); + + it('passes the currently selected date as the initial recurrence date', async () => { + const task = baseTask({ dueDay: '2026-05-20' }); + const c = await setup({ task }); + c.selectedDate = new Date(2026, 4, 27); + + await c.openRepeatDialog(); + + const dialogArg = matDialogSpy.open.calls.mostRecent().args[1] as { + data: { initialStartDate: string; targetDate: string }; + }; + expect(dialogArg.data.initialStartDate).toBe('2026-05-27'); + expect(dialogArg.data.targetDate).toBe('2026-05-27'); + }); + + it('falls back to the task creation date when no schedule date is selected', async () => { + const task = baseTask({ created: new Date(2024, 2, 14).getTime() }); + const c = await setup({ task }); + + await c.openRepeatDialog(); + + const dialogArg = matDialogSpy.open.calls.mostRecent().args[1] as { + data: { initialStartDate: string }; + }; + expect(dialogArg.data.initialStartDate).toBe('2024-03-14'); + }); + + it('suppresses confirmation only for the exact config created by this dialog', async () => { + const liveTask$ = new BehaviorSubject(baseTask()); + const c = await setup( + { task: baseTask() }, + { liveTask$, repeatDialogResult: 'cfg-created-here' }, + ); + + await c.openRepeatDialog(); + liveTask$.next(baseTask({ repeatCfgId: 'cfg-created-here' })); + await c.openRepeatDialog(); + + const dialogArg = matDialogSpy.open.calls.mostRecent().args[1] as { + data: { isRemoveConfirmationRequired: boolean }; + }; + expect(dialogArg.data.isRemoveConfirmationRequired).toBeFalse(); + }); + + it('keeps remove confirmation when the task was already repeating on open', async () => { + const repeatingTask = baseTask({ repeatCfgId: 'cfg-1' }); + const c = await setup({ task: repeatingTask }, { liveTask: repeatingTask }); + + await c.openRepeatDialog(); + + const dialogArg = matDialogSpy.open.calls.mostRecent().args[1] as { + data: { isRemoveConfirmationRequired: boolean }; + }; + expect(dialogArg.data.isRemoveConfirmationRequired).toBeTrue(); + }); + }); +}); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts index 41d85c748b..3b954b75b0 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts @@ -79,6 +79,8 @@ describe('DialogEditTaskRepeatCfgComponent', () => { task?: TaskCopy; repeatCfg?: TaskRepeatCfg; targetDate?: string; + initialStartDate?: string; + isRemoveConfirmationRequired?: boolean; }, getRepeatCfgReturnValue?: | Observable @@ -95,6 +97,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => { 'getTaskRepeatCfgByIdAllowUndefined$', 'updateTaskRepeatCfg', 'addTaskRepeatCfgToTask', + 'deleteTaskRepeatCfg', 'deleteTaskRepeatCfgWithDialog', ]); mockDateService = jasmine.createSpyObj('DateService', [ @@ -354,6 +357,76 @@ describe('DialogEditTaskRepeatCfgComponent', () => { }); }); + describe('new config initialization', () => { + it('uses the explicit initial start date from the schedule dialog', async () => { + const taskWithStoredDueDate = { + ...mockTask, + dueDay: '2026-06-01', + } as TaskCopy; + const fixture = await setupTestBed({ + task: taskWithStoredDueDate, + initialStartDate: '2026-06-12', + }); + + expect(fixture.componentInstance.repeatCfg().startDate).toBe('2026-06-12'); + }); + + it('returns the created config ID when saving', async () => { + const fixture = await setupTestBed({ task: mockTask }); + mockTaskRepeatCfgService.addTaskRepeatCfgToTask.and.callFake( + () => 'created-repeat-cfg', + ); + + fixture.componentInstance.save(); + + expect(mockDialogRef.close).toHaveBeenCalledOnceWith('created-repeat-cfg'); + }); + }); + + describe('remove', () => { + it('removes without confirmation when the config was created from the schedule dialog', fakeAsync(async () => { + const taskWithRepeatCfg = { + ...mockTask, + repeatCfgId: 'repeat-cfg-123', + } as TaskCopy; + const fixture = await setupTestBed( + { + task: taskWithRepeatCfg, + isRemoveConfirmationRequired: false, + }, + of(mockRepeatCfg), + ); + fixture.detectChanges(); + tick(); + + fixture.componentInstance.remove(); + + expect(mockTaskRepeatCfgService.deleteTaskRepeatCfg).toHaveBeenCalledOnceWith( + 'repeat-cfg-123', + ); + expect( + mockTaskRepeatCfgService.deleteTaskRepeatCfgWithDialog, + ).not.toHaveBeenCalled(); + })); + + it('keeps confirmation for a pre-existing repeat config', fakeAsync(async () => { + const taskWithRepeatCfg = { + ...mockTask, + repeatCfgId: 'repeat-cfg-123', + } as TaskCopy; + const fixture = await setupTestBed({ task: taskWithRepeatCfg }, of(mockRepeatCfg)); + fixture.detectChanges(); + tick(); + + fixture.componentInstance.remove(); + + expect( + mockTaskRepeatCfgService.deleteTaskRepeatCfgWithDialog, + ).toHaveBeenCalledOnceWith('repeat-cfg-123'); + expect(mockTaskRepeatCfgService.deleteTaskRepeatCfg).not.toHaveBeenCalled(); + })); + }); + describe('plannedStartDateStr localization (#8987 follow-up)', () => { it('renders the start-date value in the UI language under the ISO option, not sv', async () => { const fixture = await setupTestBed({ task: mockTask }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts index e8fc155246..e7f88793ba 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts @@ -218,7 +218,9 @@ export class DialogEditTaskRepeatCfgComponent { task?: Task; repeatCfg?: TaskRepeatCfg; targetDate?: string; + initialStartDate?: string; defaultRemindOption?: TaskReminderOptionId; + isRemoveConfirmationRequired?: boolean; }>(MAT_DIALOG_DATA); T: typeof T = T; @@ -334,6 +336,7 @@ export class DialogEditTaskRepeatCfgComponent { // Re-derived from the final schedule on save in case the user switches. skipOverdue: getDefaultSkipOverdue(DEFAULT_TASK_REPEAT_CFG), startDate: + this._data.initialStartDate ?? this._data.task.dueDay ?? getDbDateStr(this._data.task.dueWithTime || undefined), startTime, @@ -497,12 +500,12 @@ export class DialogEditTaskRepeatCfgComponent { const newRepeatCfg = skipOverdueTouched ? finalRepeatCfg : { ...finalRepeatCfg, skipOverdue: getDefaultSkipOverdue(finalRepeatCfg) }; - this._taskRepeatCfgService.addTaskRepeatCfgToTask( + const createdRepeatCfgId = this._taskRepeatCfgService.addTaskRepeatCfgToTask( (this._data.task as Task).id, (this._data.task as Task).projectId || null, newRepeatCfg, ); - this.close(); + this.close(createdRepeatCfgId); } } @@ -533,9 +536,12 @@ export class DialogEditTaskRepeatCfgComponent { remove(): void { const currentRepeatCfg = this.repeatCfg(); - this._taskRepeatCfgService.deleteTaskRepeatCfgWithDialog( - exists((currentRepeatCfg as TaskRepeatCfg).id), - ); + const repeatCfgId = exists((currentRepeatCfg as TaskRepeatCfg).id); + if (this._data.isRemoveConfirmationRequired !== false) { + this._taskRepeatCfgService.deleteTaskRepeatCfgWithDialog(repeatCfgId); + } else { + this._taskRepeatCfgService.deleteTaskRepeatCfg(repeatCfgId); + } this.close(); } @@ -571,8 +577,8 @@ export class DialogEditTaskRepeatCfgComponent { }); } - close(): void { - this._matDialogRef.close(); + close(createdRepeatCfgId?: string): void { + this._matDialogRef.close(createdRepeatCfgId); } addTag(id: string): void { diff --git a/src/app/features/task-repeat-cfg/task-repeat-cfg.service.spec.ts b/src/app/features/task-repeat-cfg/task-repeat-cfg.service.spec.ts index d931714a78..c334444d2e 100644 --- a/src/app/features/task-repeat-cfg/task-repeat-cfg.service.spec.ts +++ b/src/app/features/task-repeat-cfg/task-repeat-cfg.service.spec.ts @@ -175,7 +175,11 @@ describe('TaskRepeatCfgService', () => { repeatCycle: 'DAILY', }; - service.addTaskRepeatCfgToTask(taskId, projectId, taskRepeatCfg as any); + const createdRepeatCfgId = service.addTaskRepeatCfgToTask( + taskId, + projectId, + taskRepeatCfg as any, + ); expect(dispatchSpy).toHaveBeenCalledWith( jasmine.objectContaining({ @@ -188,6 +192,9 @@ describe('TaskRepeatCfgService', () => { }), }), ); + expect(createdRepeatCfgId).toBe( + dispatchSpy.calls.mostRecent().args[0].taskRepeatCfg.id, + ); }); }); diff --git a/src/app/features/task-repeat-cfg/task-repeat-cfg.service.ts b/src/app/features/task-repeat-cfg/task-repeat-cfg.service.ts index 40affee836..2077da0db2 100644 --- a/src/app/features/task-repeat-cfg/task-repeat-cfg.service.ts +++ b/src/app/features/task-repeat-cfg/task-repeat-cfg.service.ts @@ -80,21 +80,23 @@ export class TaskRepeatCfgService { taskId: string, projectId: string | null, taskRepeatCfg: Omit, - ): void { + ): string { // Note: First occurrence calculation and lastTaskCreationDay update // is handled by the updateTaskAfterMakingItRepeatable$ effect (#5594) + const repeatCfgId = nanoid(); this._store$.dispatch( addTaskRepeatCfgToTask({ taskRepeatCfg: { ...taskRepeatCfg, projectId, - id: nanoid(), + id: repeatCfgId, }, taskId, startTime: taskRepeatCfg.startTime, remindAt: taskRepeatCfg.remindAt, }), ); + return repeatCfgId; } deleteTaskRepeatCfg(id: string): void { diff --git a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html index 869e23959b..413d6ff89c 100644 --- a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html +++ b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html @@ -129,7 +129,9 @@ (collapseParent)="collapseParent()" (editActionTriggered)="scheduleTask()" (keyPress)="onItemKeyPress($event)" - [inputIcon]="task().dueWithTime || task().dueDay ? 'edit' : 'add'" + [inputIcon]=" + task().dueWithTime || task().dueDay || task().repeatCfgId ? 'edit' : 'add' + " class="input-item" [class.color-warn]="isOverdue()" > @@ -138,24 +140,32 @@ {{ scheduleLabelKey() | translate }} - @if (task().dueWithTime) { -
- {{ task().dueWithTime | localeDate: 'short' : undefined : locale() }} -
- } - @if (task().dueDay) { -
- @if (isPlannedForTodayDay()) { - wb_sunny - {{ T.G.TODAY_TAG_TITLE | translate }} - } @else { - {{ task().dueDay | localeDate: 'shortDate' : undefined : locale() }} - } -
- } +
+ @if (task().dueWithTime) { +
+ {{ task().dueWithTime | localeDate: 'short' : undefined : locale() }} +
+ } + @if (task().dueDay) { +
+ @if (isPlannedForTodayDay()) { + wb_sunny + {{ T.G.TODAY_TAG_TITLE | translate }} + } @else { + {{ task().dueDay | localeDate: 'shortDate' : undefined : locale() }} + } +
+ } + @if (task().repeatCfgId && repeatCfgLabel()) { +
+ repeat + {{ T.F.TASK.ADDITIONAL_INFO.REPEAT | translate }}: + + {{ repeatCfgLabel() }} +
+ } +
} - @if (!task().parentId && !task().issueId) { - - - repeat - {{ T.F.TASK.ADDITIONAL_INFO.REPEAT | translate }} - - - @if (repeatCfgLabel()) { -
- {{ repeatCfgLabel() }} -
- } -
-
- } - span { + min-width: 0; + } +} + +.schedule-value__repeat { + color: var(--text-color-muted); +} + .attachment-list-panel-content-wrapper { padding-bottom: var(--s); } diff --git a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.spec.ts b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.spec.ts index 50a19bcdb5..df294074d6 100644 --- a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.spec.ts +++ b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.spec.ts @@ -277,6 +277,52 @@ describe('TaskDetailPanelComponent', () => { expect(component.isExpandedNotesPanel()).toBeFalse(); }); }); + + describe('showScheduleIcon (merged schedule + recurrence item)', () => { + it("returns 'today' when a due day is set", () => { + componentRef.setInput('task', { + ...MOCK_TASK, + dueDay: '2026-05-26', + dueWithTime: undefined, + repeatCfgId: undefined, + }); + fixture.detectChanges(); + expect(component.showScheduleIcon()).toBe('today'); + }); + + it("returns 'schedule' for a timed due date without a reminder", () => { + componentRef.setInput('task', { + ...MOCK_TASK, + dueDay: undefined, + dueWithTime: 1700000000000, + remindAt: undefined, + repeatCfgId: undefined, + }); + fixture.detectChanges(); + expect(component.showScheduleIcon()).toBe('schedule'); + }); + + it("returns 'repeat' for a recurring task with no due date", () => { + componentRef.setInput('task', { + ...MOCK_TASK, + dueDay: undefined, + dueWithTime: undefined, + repeatCfgId: 'cfg-1', + }); + fixture.detectChanges(); + expect(component.showScheduleIcon()).toBe('repeat'); + }); + + it('prefers the due-day icon over the repeat icon when a recurring task also has a due day', () => { + componentRef.setInput('task', { + ...MOCK_TASK, + dueDay: '2026-05-26', + repeatCfgId: 'cfg-1', + }); + fixture.detectChanges(); + expect(component.showScheduleIcon()).toBe('today'); + }); + }); }); const fakeTask = (id: string): TaskWithSubTasks => diff --git a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.ts b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.ts index 973ea2412d..84bf07499e 100644 --- a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.ts +++ b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.ts @@ -38,7 +38,6 @@ import { fadeAnimation } from '../../../ui/animations/fade.ani'; import { swirlAnimation } from '../../../ui/animations/swirl-in-out.ani'; import { DialogTimeEstimateComponent } from '../dialog-time-estimate/dialog-time-estimate.component'; import { MatDialog } from '@angular/material/dialog'; -import { DialogEditTaskRepeatCfgComponent } from '../../task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component'; import { TaskRepeatCfgService } from '../../task-repeat-cfg/task-repeat-cfg.service'; import { DialogEditTaskAttachmentComponent } from '../task-attachment/dialog-edit-attachment/dialog-edit-task-attachment.component'; import { TaskDetailItemComponent } from './task-additional-info-item/task-detail-item.component'; @@ -397,6 +396,7 @@ export class TaskDetailPanelComponent implements OnInit, AfterViewInit, OnDestro const task = this.task(); if (task.dueDay) return 'today'; if (task.dueWithTime && !task.remindAt) return 'schedule'; + if (task.repeatCfgId) return 'repeat'; return 'alarm'; }); @@ -652,16 +652,6 @@ export class TaskDetailPanelComponent implements OnInit, AfterViewInit, OnDestro this._store.dispatch(TaskSharedActions.removeDeadline({ taskId: this.task().id })); } - editTaskRepeatCfg(): void { - this._matDialog.open(DialogEditTaskRepeatCfgComponent, { - restoreFocus: true, - data: { - task: this.task(), - targetDate: this.task().dueDay || getDbDateStr(new Date(this.task().created)), - }, - }); - } - addAttachment(): void { this._matDialog .open(DialogEditTaskAttachmentComponent, { diff --git a/src/app/t.const.ts b/src/app/t.const.ts index f52658cd15..542560de91 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1979,6 +1979,7 @@ const T = { UNSCHEDULE_ALL: 'F.TASK.D_REMINDER_VIEW.UNSCHEDULE_ALL', }, D_SCHEDULE_TASK: { + DOES_NOT_REPEAT: 'F.TASK.D_SCHEDULE_TASK.DOES_NOT_REPEAT', INFO_OUTSIDE_WORK_HOURS: 'F.TASK.D_SCHEDULE_TASK.INFO_OUTSIDE_WORK_HOURS', INFO_OVERLAP: 'F.TASK.D_SCHEDULE_TASK.INFO_OVERLAP', QA_NEXT_MONTH: 'F.TASK.D_SCHEDULE_TASK.QA_NEXT_MONTH', @@ -1986,6 +1987,7 @@ const T = { QA_TODAY: 'F.TASK.D_SCHEDULE_TASK.QA_TODAY', QA_TOMORROW: 'F.TASK.D_SCHEDULE_TASK.QA_TOMORROW', REMIND_AT: 'F.TASK.D_SCHEDULE_TASK.REMIND_AT', + REPEAT: 'F.TASK.D_SCHEDULE_TASK.REPEAT', RO_1H: 'F.TASK.D_SCHEDULE_TASK.RO_1H', RO_5M: 'F.TASK.D_SCHEDULE_TASK.RO_5M', RO_10M: 'F.TASK.D_SCHEDULE_TASK.RO_10M', diff --git a/src/app/ui/datetime-picker/datetime-picker.component.html b/src/app/ui/datetime-picker/datetime-picker.component.html index 5b4514a51a..3d54931cfa 100644 --- a/src/app/ui/datetime-picker/datetime-picker.component.html +++ b/src/app/ui/datetime-picker/datetime-picker.component.html @@ -55,7 +55,10 @@ }
- + {{ timeLabel() }} schedule alarm {{ reminderLabel() | translate }} diff --git a/src/app/ui/datetime-picker/datetime-picker.component.scss b/src/app/ui/datetime-picker/datetime-picker.component.scss index 92a80364bc..41ace71935 100644 --- a/src/app/ui/datetime-picker/datetime-picker.component.scss +++ b/src/app/ui/datetime-picker/datetime-picker.component.scss @@ -51,8 +51,11 @@ } ::ng-deep { + // A 6-row month needs ~353px (40px header + ~313px month view); the previous + // 400px left ~47px of dead space between the grid and the time input. 360px + // fits the tallest month with a small buffer and no clipping. mat-calendar { - height: 400px; + height: 360px; } .mat-calendar-header { @@ -175,10 +178,13 @@ } .form-ctrl-wrapper { - margin: var(--s2) var(--s2) var(--s2); + // No bottom margin: each field already carries ~16px of trailing space, which + // matches the gap between the stacked fields. Adding a bottom margin here would + // double it, leaving whatever follows (repeat button, dialog actions) sitting + // twice as far from the inputs as the inputs sit from each other. + margin: 0 var(--s2); display: flex; flex-direction: column; - gap: var(--s); mat-form-field { width: 100%; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index bdc5ab897e..13d419313a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1814,7 +1814,7 @@ "NOTES": "Note", "PARENT": "Parent", "REMINDER": "Reminder", - "REPEAT": "Recur", + "REPEAT": "Repeat", "SCHEDULE_TASK": "Schedule Task", "SUB_TASKS": "Subtasks ({{nr}})", "TIME": "Duration", @@ -1929,6 +1929,7 @@ "UNSCHEDULE_ALL": "Unschedule all" }, "D_SCHEDULE_TASK": { + "DOES_NOT_REPEAT": "Does not repeat", "INFO_OUTSIDE_WORK_HOURS": "Falls outside your configured work hours.", "INFO_OVERLAP": "Overlaps with another scheduled task.", "QA_NEXT_MONTH": "Schedule next month", @@ -1936,6 +1937,7 @@ "QA_TODAY": "Schedule today", "QA_TOMORROW": "Schedule tomorrow", "REMIND_AT": "Remind at", + "REPEAT": "Repeat", "RO_1H": "1 hour before it starts", "RO_5M": "5 minutes before it starts", "RO_10M": "10 minutes before it starts",