feat(planner): integrate recurring config into schedule dialog (#9286)

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
Johannes Millan 2026-07-24 15:49:12 +02:00 committed by GitHub
parent 0abb87c35a
commit fe3b97524a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 703 additions and 243 deletions

View file

@ -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 nonissue-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 tasks 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 tasks **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”)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Locator> => {
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<void> => {
export const openRecurScheduleDialog = async (page: Page): Promise<Locator> => {
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<void> => {
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<void> => {
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 });
}
};
/**

View file

@ -16,6 +16,25 @@
></datetime-picker>
}
@if (canRepeat) {
@let repeatLabel = repeatCfgLabel();
<button
type="button"
mat-stroked-button
class="repeat-btn"
(click)="openRepeatDialog()"
>
<mat-icon>repeat</mat-icon>
{{ T.F.TASK.D_SCHEDULE_TASK.REPEAT | translate }}:
<span
class="repeat-value"
[class.repeat-value--empty]="!repeatLabel"
>
{{ repeatLabel || (T.F.TASK.D_SCHEDULE_TASK.DOES_NOT_REPEAT | translate) }}
</span>
</button>
}
<div class="dialog-actions-and-warnings">
@if (
selectedTime &&

View file

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

View file

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

View file

@ -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<DialogScheduleTaskComponent>>(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<Task | null> = 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<Date | string | null>(null);
private _selectedTime = signal<string | null>(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<void> {
// 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) {

View file

@ -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<MatDialog>;
const baseTask = (partial: Partial<TaskCopy> = {}): 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<string, unknown>,
opts: {
liveTask?: TaskCopy;
liveTask$?: Observable<TaskCopy | null>;
repeatDialogResult?: string;
} = {},
): Promise<DialogScheduleTaskComponent> => {
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<unknown, string | undefined>);
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<TaskCopy | null>(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();
});
});
});

View file

@ -79,6 +79,8 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
task?: TaskCopy;
repeatCfg?: TaskRepeatCfg;
targetDate?: string;
initialStartDate?: string;
isRemoveConfirmationRequired?: boolean;
},
getRepeatCfgReturnValue?:
| Observable<TaskRepeatCfg | undefined>
@ -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 });

View file

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

View file

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

View file

@ -80,21 +80,23 @@ export class TaskRepeatCfgService {
taskId: string,
projectId: string | null,
taskRepeatCfg: Omit<TaskRepeatCfgCopy, 'id'>,
): 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 {

View file

@ -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 @@
<span>{{ scheduleLabelKey() | translate }}</span>
</ng-container>
<ng-container input-value>
@if (task().dueWithTime) {
<div
class="reminder-value"
matTooltipPosition="left"
>
{{ task().dueWithTime | localeDate: 'short' : undefined : locale() }}
</div>
}
@if (task().dueDay) {
<div class="reminder-value">
@if (isPlannedForTodayDay()) {
<mat-icon>wb_sunny</mat-icon>
{{ T.G.TODAY_TAG_TITLE | translate }}
} @else {
{{ task().dueDay | localeDate: 'shortDate' : undefined : locale() }}
}
</div>
}
<div class="schedule-value">
@if (task().dueWithTime) {
<div class="schedule-value__date">
{{ task().dueWithTime | localeDate: 'short' : undefined : locale() }}
</div>
}
@if (task().dueDay) {
<div class="schedule-value__date">
@if (isPlannedForTodayDay()) {
<mat-icon>wb_sunny</mat-icon>
{{ T.G.TODAY_TAG_TITLE | translate }}
} @else {
{{ task().dueDay | localeDate: 'shortDate' : undefined : locale() }}
}
</div>
}
@if (task().repeatCfgId && repeatCfgLabel()) {
<div class="schedule-value__repeat">
<mat-icon>repeat</mat-icon>
<span class="cdk-visually-hidden"
>{{ T.F.TASK.ADDITIONAL_INFO.REPEAT | translate }}:
</span>
<span>{{ repeatCfgLabel() }}</span>
</div>
}
</div>
</ng-container>
</task-detail-item>
<task-detail-item
@ -323,31 +333,6 @@
<ng-container input-value></ng-container>
</task-detail-item>
}
@if (!task().parentId && !task().issueId) {
<task-detail-item
(collapseParent)="collapseParent()"
(editActionTriggered)="editTaskRepeatCfg()"
(keyPress)="onItemKeyPress($event)"
[inputIcon]="task().repeatCfgId ? 'edit' : 'add'"
class="input-item"
>
<ng-container input-title>
<mat-icon>repeat</mat-icon>
<span>{{ T.F.TASK.ADDITIONAL_INFO.REPEAT | translate }}</span>
</ng-container>
<ng-container input-value>
@if (repeatCfgLabel()) {
<div
class="reminder-value"
matTooltipPosition="left"
>
{{ repeatCfgLabel() }}
</div>
}
</ng-container>
</task-detail-item>
}
<task-detail-item
#tagEditDetailItemElRef
(collapseParent)="collapseParent()"

View file

@ -1,6 +1,11 @@
@use '@angular/cdk' as cdk;
@use '../../../../styles/_globals.scss' as *;
@use '../task/task.component.mixins' as *;
// Provides the .cdk-visually-hidden helper used for the screen-reader-only
// "Repeat" prefix on the recurrence line (encapsulation-scoped, not global).
@include cdk.a11y-visually-hidden();
:host {
position: relative;
z-index: 1;
@ -120,6 +125,42 @@ progress-bar {
margin-left: var(--s-quarter);
}
// Merged schedule item: the due date is the primary value, the recurrence a
// secondary line stacked beneath it (instead of competing side by side).
.schedule-value {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s-quarter);
min-width: 0;
}
.schedule-value__date,
.schedule-value__repeat {
display: inline-flex;
align-items: center;
gap: var(--s-quarter);
max-width: 100%;
overflow: hidden;
mat-icon {
flex: 0 0 auto;
font-size: 18px;
width: 18px;
height: 18px;
}
// Let a long recurrence label (e.g. "Monthly on the first Thursday") shrink and
// ellipsis within the flex row (nowrap/ellipsis come from .input-item__value span).
> span {
min-width: 0;
}
}
.schedule-value__repeat {
color: var(--text-color-muted);
}
.attachment-list-panel-content-wrapper {
padding-bottom: var(--s);
}

View file

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

View file

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

View file

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

View file

@ -55,7 +55,10 @@
}
<div class="form-ctrl-wrapper">
<mat-form-field class="time-form-field">
<mat-form-field
class="time-form-field"
subscriptSizing="dynamic"
>
<mat-label>{{ timeLabel() }}</mat-label>
<mat-icon matPrefix>schedule</mat-icon>
<input
@ -83,6 +86,7 @@
<mat-form-field
[@expandFade]
class="reminder-form-field"
subscriptSizing="dynamic"
>
<mat-icon matPrefix>alarm</mat-icon>
<mat-label>{{ reminderLabel() | translate }}</mat-label>

View file

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

View file

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