From fe3b97524a1239716c91efdd04ccf9fa4cf1a716 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 24 Jul 2026 15:49:12 +0200 Subject: [PATCH] feat(planner): integrate recurring config into schedule dialog (#9286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(planner): add Repeat button to schedule dialog Surface recurrence directly from the schedule dialog via a "Repeat" button that opens the existing repeat-config dialog, so setting a task to recur no longer requires the separate detail-panel row. - Button mirrors the repeat dialog's own start-date button styling and shows the live recurrence label ("Daily", "Mon–Fri", ...) or "Does not repeat", with an edit/chevron affordance. - Gated to top-level, non-issue tasks (matching the panel's Repeat row) and hidden in isSelectDueOnly mode to avoid a circular picker. - Recurrence start is seeded from the date currently selected in the dialog, falling back to the task's due day / creation date. - Label reads the repeat cfg via the store selector (not the service) to keep the dialog's dependency surface minimal. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC * feat(tasks): merge recurring into the schedule item in task detail panel Fold the standalone "Repeat" row into the schedule/due item so a task's timing lives in one place. The schedule item now also shows the recurrence label (e.g. "Daily") beside the due date, and recurrence is edited via the Repeat button now hosted in the schedule dialog. - Remove the separate Repeat row and the now-unused editTaskRepeatCfg() opener; recurrence editing goes through the schedule dialog. - Show a repeat chip in the schedule item's value when repeatCfgId is set, and treat repeatCfgId as "has a value" for the add/edit icon. - showScheduleIcon() falls back to 'repeat' for a recurring task with no due date instead of the misleading 'alarm'. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC * style(datetime-picker): tighten spacing in the schedule picker The date/time picker left noticeable dead space between the calendar and the time input, and between the stacked time and reminder inputs. - Shrink the fixed calendar height 400px -> 360px. A 6-row month (the structural max) needs ~353px, so the old value left ~47px of empty space below the grid; 360px fits every month with no clipping. - Drop the calendar-to-inputs top margin and the reserved Material form-field subscript row (these fields never show hints and always carry a valid value), so the time and reminder inputs sit closer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC * style(tasks): stack recurrence under the due date in the schedule item When a task was both planned and recurring, the schedule item crammed the date ("Today") and the recurrence ("Every day") side by side in the narrow value column, squeezing both and truncating the recurrence label. Present them as a hierarchy instead: the due date is the primary value and the recurrence is a muted secondary line stacked beneath it. Reads clearly, avoids truncation, and gives the row label room to breathe. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC * style(datetime-picker): unify vertical spacing below the inputs The form-ctrl-wrapper carried a bottom margin on top of each field's own ~16px trailing space, so whatever followed the inputs sat 32px away — twice the gap between the stacked fields themselves. In the schedule dialog this left the Repeat button visibly detached; the deadline dialog had the same doubled gap before its actions. Drop the redundant bottom margin so the inputs, the Repeat button, and the dialog actions all share one consistent 16px rhythm. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC * fix(planner): harden schedule-dialog recurrence + polish merged item Review follow-ups across the recurring/planned consolidation: - Fix a duplicate-cfg bug: openRepeatDialog seeded the repeat dialog from the frozen MAT_DIALOG_DATA snapshot, so re-opening after a repeat was just created routed to "create" again and orphaned the first cfg. Seed from the live store task instead, so the second open edits. - Drop the ineffective "seed recurrence start from the selected date" logic and its misleading comment: the repeat dialog derives the start from the task's due date; targetDate only drives the skip-instance UI. Behaviour now matches task.component's opener exactly. - Break the schedule-dialog <-> repeat-dialog module cycle with a lazy import, matching task.component / add-task-bar. - Panel: give the recurrence line a screen-reader "Repeat:" prefix (cdk-visually-hidden) and let long labels ellipsis within the flex row. - Align terminology: ADDITIONAL_INFO.REPEAT "Recur" -> "Repeat" (the add-task-bar and schedule dialog already say "Repeat"). - Use --bar-height token instead of a raw 48px in .repeat-btn. - Add coverage: canRepeat gating (incl. isSelectDueOnly), the live-task seeding regression, and showScheduleIcon's new 'repeat' branch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC * test(recurring): follow schedule dialog repeat flow Update shared Playwright helpers and direct callers after the standalone Repeat row moved into the schedule dialog. * style(planner): remove one-off Material overrides Use stock button presentation and the supported dynamic subscript API instead of local Material internals. * style(planner): distinguish configured repeat values Emphasize active recurrence while keeping the non-repeating state visibly muted. * fix(recurring): avoid warning for newly added config Require removal confirmation only when recurrence already existed before the enclosing schedule dialog opened. * fix(recurring): preserve schedule context in repeat dialog Seed new repeat configs from the current Schedule selection and suppress removal confirmation only for the exact config created in that dialog session. Update the repeating-task guide and add regression coverage for date and config provenance. * test(recurring): target combined schedule value --------- Co-authored-by: Claude --- docs/wiki/2.06-Manage-Repeating-Tasks.md | 8 +- .../invalid-clock-string-bug-7067.spec.ts | 24 +- ...curring-future-start-date-bug-6856.spec.ts | 31 +-- .../recurring-missed-day-bug-4559.spec.ts | 15 +- ...ing-preserve-past-due-day-bug-7344.spec.ts | 15 +- ...urring-skip-and-remove-persistence.spec.ts | 8 +- ...ecurring-start-date-epoch-bug-6860.spec.ts | 42 +-- .../repeat-task-day-change-bug-6230.spec.ts | 15 +- ...eat-task-first-occurrence-bug-5594.spec.ts | 29 +-- ...epeat-timed-cold-reopen-day-change.spec.ts | 29 +-- e2e/utils/recurring-task-helpers.ts | 49 +++- .../dialog-schedule-task.component.html | 19 ++ ...hedule-task.component.invalid-time.spec.ts | 7 +- .../dialog-schedule-task.component.scss | 14 + .../dialog-schedule-task.component.ts | 92 +++++++ .../dialog-schedule-task.repeat.spec.ts | 245 ++++++++++++++++++ ...log-edit-task-repeat-cfg.component.spec.ts | 73 ++++++ .../dialog-edit-task-repeat-cfg.component.ts | 20 +- .../task-repeat-cfg.service.spec.ts | 9 +- .../task-repeat-cfg.service.ts | 6 +- .../task-detail-panel.component.html | 73 +++--- .../task-detail-panel.component.scss | 41 +++ .../task-detail-panel.component.spec.ts | 46 ++++ .../task-detail-panel.component.ts | 12 +- src/app/t.const.ts | 2 + .../datetime-picker.component.html | 6 +- .../datetime-picker.component.scss | 12 +- src/assets/i18n/en.json | 4 +- 28 files changed, 703 insertions(+), 243 deletions(-) create mode 100644 src/app/features/planner/dialog-schedule-task/dialog-schedule-task.repeat.spec.ts 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",