Update recurrent task calendar and general calendar design (#8017)

* feat(task-repeat-cfg): use schedule dialog picker for recurring task start date

* refactor(calendar): extract shared DateTimePickerComponent and use in schedule/deadline dialogs

* style(calendar): make primary button gray when disabled and stronger green when enabled

* style(calendar): upgrade date picker calendar visuals and remove outlines

* fix(task-repeat-cfg): prevent premature duration formatting during typing

* fix: resolve merge conflict and fix lint errors in dialog components

* test(e2e): update recurring task tests to match new schedule dialog UI

* test(e2e): fix regressions and ambiguous locators in recurring task tests

Resolves strict mode violations for 'Schedule' button and updates locators to handle the new separate schedule dialog robustly using the datetime-picker filter.

* fix: hide unschedule button in schedule dialog when opened from recurring task cfg

* fix(datetime-picker): restore calendar first-week weekday alignment by using visibility:hidden instead of display:none for body-label cell

* fix(i18n): use active UI language for date locale fallback, show full month names, and translate hardcoded Time label

* test: fix DateTimeFormatService unit tests by mocking TranslateService

* fix(task-repeat): add default reminders to recurring tasks

* test(sync): increase lock reentry timeout to prevent flakiness

* style(ui): align calendar cell shapes and make hover color darker

* style(ui): style inputs and action buttons in schedule and deadline dialogs

* feat(task-repeat): enable quick access scheduling for recurring tasks

* feat(ui): separate month and year into distinct header buttons in datetime picker

* style(ui): show dropdown arrow next to the year only

* fix(locale): map UI fallbacks to parse-safe locales and sync DateAdapter

* fix(task-repeat): fix option caching, DST normalization, and schedule dialog time preservation

* test(task-repeat): mock formatTime in repeat dialog spec

* fix(ui): improve datetime-picker month/year picking, arrow direction, transitions, and highlighter sync

* fix(ui): defer year selection view transition and avoid year picker pagination updating highlighter

* style(ui): restore focus outline and default material look

* style(ui): drop custom hover circle and pill shape, and revert month-grid names to default short format

* fix(ui): gate schedule warnings on isSelectDueOnly

* style(ui): remove custom button color overrides

* style(ui): restore default Material hover and focus behavior

* fix(ui): add calendar header aria-labels and mirror arrows in RTL

* test(e2e): fix recurring task start date specs for new datetime picker

* revert(date-time): restore master's browser-region-first fallback in DateTimeFormatService

* fix(ui): make quick-access tooltips configurable on datetime-picker and preserve deadline labels

* fix(ui): exclude disabled previous/next calendar buttons from custom hover style

* fix(ui): align period header button aria-labels with actual view transitions

* style(ui): refactor recurring start date button styling with logical CSS and remove any type

* fix(ui): preserve selected year when navigating in year picker and toggling back

* style(ui): nudge default calendar cell hover with a shared token

* fix(datetime-picker): sync hover selection with keyboard navigation and focus on open

* fix(deadline): grey out past days in deadline calendar

* style(datetime-picker): use shared calendar hover background for header buttons

* style(calendar): use primary mixed color globally for calendar hover background

* style(datetime-picker): hide mouse on arrow key nav and unify hover/focus colors

* style(datetime-picker): resolve selected cell highlight bug and update hover outlines

* style(datetime-picker): style current date/month/year selectors with accent color

* fix(task-repeat-cfg): guard repeat schedule opening with isValidSplitTime

* fix(task-repeat-cfg): only persist remindAt when a valid time is present

* fix(ui): add disabled guards for calendar cells and improve scheduling result

* fix(ui): improve focus management and navigation in datetime-picker

* fix(ui): prevent invalid 24:00 time in datetime-picker and remove debug log

* refactor(ui): cleanup datetime-picker and improve calendar header logic

* feat(ui): enable year navigation and consistent month selection in datetime-picker

* fix(ui): align multi-year boundary logic with Angular Material anchoring

* fix(repeat): restore past start-date floor for existing repeat configs

* test(e2e): fix setRecurStartDate to correctly navigate month and year

* test(e2e): remove duplicate helper declarations causing SyntaxError

* refactor(calendar): de-risk calendar by reverting brittle hover sync and custom header

* test(calendar): update unit tests for datetime-picker de-risking

* fix(calendar): restore standard year-to-month drill-down navigation

* test(e2e): fix recurring task and planner task compatibility

- Update setRecurStartDate helper to use standard Material calendar
  drill-down navigation, fixing compatibility with the new UI.
- Update TaskPage helper to support both 'task' and 'planner-task'
  elements in board and planner views.

* test(e2e): fix recurring task date selection and calendar navigation

- Update setRecurStartDate helper to use correct click sequence for the new date selector header.
- Remove minDate restriction in recurring task edit dialog to allow moving start dates earlier (#7423).

* style(ui): remove stale calendar overrides and localize header labels

* test(e2e): fix failing task detail date test due to calendar i18n label change

* fix(ui): remove redundant global locale mutation from DateTimePickerComponent

* fix(ui): prevent calendar navigation reset when date value hasn't changed

* fix(planner): wire showQuickAccess and disable auto-submit in select-due-only mode

* fix(repeat): allow past dates when editing repeat configurations

* fix(repeat): use DateService for logical today check

* fix(test): add coverage for navigation guard and interactive flows in DateTimePickerComponent

* fix(test): restore time handling coverage for select-due-only mode in DialogScheduleTaskComponent

* fix(tasks): implement robust reminder quantization using mid-points and add tests

* feat(planner): add stable data-test-id locators for schedule dialog buttons

* fix(e2e): replace fragile translated 'Schedule' locators with data-test-id

* fix(ui): improve calendar styling and i18n consistency

* refactor(ui): cleanup unused code in DateTimePickerComponent

* fix(planner): restore auto-submit on quick-access in DialogScheduleTaskComponent

* fix(repeat): refine minDate strategy for recurring configurations

* chore(i18n): restore de.json to master
This commit is contained in:
Maikel Hajiabadi 2026-06-09 16:30:22 +02:00 committed by GitHub
parent 46de133ce2
commit c109ad1fa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1467 additions and 878 deletions

View file

@ -173,7 +173,7 @@ export class DialogPage extends BasePage {
* Open calendar picker
*/
async openCalendarPicker(): Promise<void> {
const openCalendarBtn = this.page.getByRole('button', { name: 'Open calendar' });
const openCalendarBtn = this.page.locator('mat-datepicker-toggle button').first();
await openCalendarBtn.waitFor({ state: 'visible', timeout: 3000 });
await openCalendarBtn.click();
await this.page.waitForTimeout(300);

View file

@ -204,7 +204,7 @@ export class TaskPage extends BasePage {
async waitForTaskCount(expectedCount: number, timeout: number = 10000): Promise<void> {
await this.page.waitForFunction(
(args) => {
const currentCount = document.querySelectorAll('task').length;
const currentCount = document.querySelectorAll('task, planner-task').length;
return currentCount === args.expectedCount;
},
{ expectedCount },

View file

@ -36,15 +36,24 @@ test('should not crash when a repeat config has an invalid startTime in the stor
.filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) })
.click();
const repeatDialog = page.locator('mat-dialog-container');
const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
// Set a valid startTime so the config has startTime + remindAt in the store
await repeatDialog.locator('collapsible .collapsible-header').last().click();
const startTimeField = repeatDialog.getByLabel(/Scheduled start time/i);
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 startTimeField = scheduleDialog.getByLabel('Time');
await expect(startTimeField).toBeVisible({ timeout: 5000 });
await startTimeField.fill('10:30');
await startTimeField.blur();
const scheduleBtn = scheduleDialog.locator('[data-test-id="schedule-submit-btn"]');
await scheduleBtn.click();
await scheduleDialog.waitFor({ state: 'hidden' });
await page.waitForTimeout(300);
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });

View file

@ -41,29 +41,40 @@ test.describe('Recurring Task - Future Start Date (#6856)', () => {
await recurItem.click();
// 3. Wait for the repeat dialog and set a future start date via calendar
const repeatDialog = page.locator('mat-dialog-container');
const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
// Open the calendar popup
const calendarToggle = repeatDialog.locator('mat-datepicker-toggle button');
await calendarToggle.click();
// Open the schedule dialog
const scheduleBtn = repeatDialog.locator('.planned-start-date-btn');
await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
await scheduleBtn.click();
const calendar = page.locator('.mat-calendar');
// 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 calendar = scheduleDialog.locator('mat-calendar');
await expect(calendar).toBeVisible({ timeout: 5000 });
// Navigate to next month to ensure the date is in the future
const nextMonthBtn = page.getByRole('button', { name: /next month/i });
const nextMonthBtn = scheduleDialog.getByRole('button', { name: /next month/i });
await nextMonthBtn.click();
// Select the first available day in next month
const firstDay = page
const firstDay = scheduleDialog
.locator('.mat-calendar-body-cell:not(.mat-calendar-body-disabled)')
.first();
await expect(firstDay).toBeVisible({ timeout: 5000 });
await firstDay.click();
// Wait for calendar to close
await expect(calendar).not.toBeVisible({ timeout: 5000 });
// Click Schedule button
const scheduleSubmitBtn = scheduleDialog.locator(
'[data-test-id="schedule-submit-btn"]',
);
await scheduleSubmitBtn.click();
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 });

View file

@ -41,32 +41,46 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
await recurItem.click();
// 3. Wait for the repeat dialog to appear
const repeatDialog = page.locator('mat-dialog-container');
const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
// 4. Open the calendar popup and select first day of next month
const calendarToggle = repeatDialog.locator('mat-datepicker-toggle button');
await calendarToggle.click();
// 4. Open the schedule dialog
const scheduleBtn = repeatDialog.locator('.planned-start-date-btn');
await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
await scheduleBtn.click();
const calendar = page.locator('.mat-calendar');
// 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 calendar = scheduleDialog.locator('mat-calendar');
await expect(calendar).toBeVisible({ timeout: 5000 });
// Navigate to next month and select the first available day
const nextMonthBtn = page.getByRole('button', { name: /next month/i });
const nextMonthBtn = scheduleDialog.getByRole('button', { name: /next month/i });
await nextMonthBtn.click();
const firstDay = page
const firstDay = scheduleDialog
.locator('.mat-calendar-body-cell:not(.mat-calendar-body-disabled)')
.first();
await expect(firstDay).toBeVisible({ timeout: 5000 });
await firstDay.click();
// 5. Verify the date input does not show epoch
const dateInput = repeatDialog.getByRole('textbox', { name: /start date/i });
await expect(dateInput).toBeVisible();
const inputValue = await dateInput.inputValue();
expect(inputValue).not.toBe('');
expect(inputValue).not.toContain('1970');
// Click Schedule button
const scheduleSubmitBtn = scheduleDialog.locator(
'[data-test-id="schedule-submit-btn"]',
);
await scheduleSubmitBtn.click();
await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
// 5. Verify the date input/val does not show epoch
const dateVal = repeatDialog.locator('.planned-date-val');
await expect(dateVal).toBeVisible();
const valText = await dateVal.innerText();
expect(valText).not.toBe('');
expect(valText).not.toContain('1970');
// 6. Save and verify the date survives persistence
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
@ -74,8 +88,7 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
await saveBtn.click();
await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 });
});
test('should preserve start date when typing date manually into input', async ({
test('should preserve start date when configuring recurring task via helper', async ({
page,
workViewPage,
taskPage,
@ -105,7 +118,7 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
await recurItem.click();
// 3. Wait for the repeat dialog to appear
const repeatDialog = page.locator('mat-dialog-container');
const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
await setRecurStartDate(page, '15/06/2026');

View file

@ -46,17 +46,34 @@ test.describe('Repeat Task - Timed + Cold Reopen Day Change', () => {
await expect(recurItem).toBeVisible({ timeout: 5000 });
await recurItem.click();
const repeatDialog = page.locator('mat-dialog-container');
const repeatDialog = page.locator('mat-dialog-container').first();
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
// 4. Make it a TIMED daily repeat: expand Advanced, set a start time (13:00).
// 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.
await repeatDialog.locator('collapsible .collapsible-header').last().click();
const startTimeField = repeatDialog.getByLabel(/Scheduled start time/i);
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 });
// Set a valid startTime
const startTimeField = scheduleDialog.getByLabel('Time');
await expect(startTimeField).toBeVisible({ timeout: 5000 });
await startTimeField.fill('13:00');
await startTimeField.blur();
// Click Schedule button
const scheduleSubmitBtn = scheduleDialog.locator(
'[data-test-id="schedule-submit-btn"]',
);
await scheduleSubmitBtn.click();
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 });

View file

@ -54,7 +54,7 @@ test.describe('Task detail', () => {
const completedInfoText = await completedInfo.textContent();
await completedInfo.click();
await page.getByRole('button', { name: 'Open calendar' }).click();
await page.locator('mat-datepicker-toggle button').first().click();
await page.getByRole('button', { name: 'Next month' }).click();
// Picking the first day of the next month should guarantee a change
await page.locator('mat-month-view button').first().click();

View file

@ -45,32 +45,63 @@ export const openRecurDialogFromProjection = async (
};
/**
* Set the recurring "Start date" by typing into the matInput. The input parses
* the locale's display format (en-GB "DD/MM/YYYY") on blur, which is more
* robust than driving the calendar overlay across Material versions.
*
* Flake guard: the Material datepicker input intermittently drops the typed
* value while the dialog is still binding/animating. On blur the (dateChange)
* handler clears `innerValue` whenever the field hasn't yet parsed to a valid
* date, and the one-way `[ngModel]="innerValue()"` binding then re-renders the
* input as empty (`toHaveValue("")`). The previous guard wrapped only the
* fill so the value still vanished on the Tab-triggered blur. Retry the WHOLE
* type-and-commit cycle (fill + Tab) until the committed value sticks.
* Set the recurring "Start date" by using the calendar datepicker.
*/
export const setRecurStartDate = async (page: Page, ddmmyyyy: string): Promise<void> => {
const dialog = page.locator(DIALOG_CONTAINER);
const startDateInput = dialog
.locator('mat-form-field')
.filter({ hasText: /Start date/i })
.locator('input')
const dialog = page.locator(DIALOG_CONTAINER).first();
const scheduleBtn = dialog.locator('.planned-start-date-btn');
await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
await scheduleBtn.click();
const scheduleDialog = page
.locator(DIALOG_CONTAINER)
.filter({ has: page.locator('datetime-picker') });
await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 });
const calendar = scheduleDialog.locator('mat-calendar');
await expect(calendar).toBeVisible({ timeout: 5000 });
const [dayStr, monthStr, yearStr] = ddmmyyyy.split('/');
const day = parseInt(dayStr, 10);
const month = parseInt(monthStr, 10) - 1; // 0-indexed
const year = parseInt(yearStr, 10);
// Navigate to correct year
await scheduleDialog.locator('.mat-calendar-period-button').click();
const yearCell = scheduleDialog
.locator('.mat-calendar-body-cell')
.filter({ hasText: new RegExp(`^\\s*${year}\\s*$`) })
.first();
await expect(startDateInput).toBeVisible({ timeout: 5000 });
await expect(async () => {
await startDateInput.fill('');
await startDateInput.fill(ddmmyyyy);
await startDateInput.press('Tab');
await expect(startDateInput).toHaveValue(ddmmyyyy, { timeout: 1000 });
}).toPass({ timeout: 10000 });
await expect(yearCell).toBeVisible({ timeout: 5000 });
await yearCell.click();
// Navigate to correct month
const monthCell = scheduleDialog
.locator('.mat-calendar-body-cell')
.filter({
hasText: new RegExp(
`^\\s*${new Intl.DateTimeFormat('en-US', { month: 'short' }).format(new Date(year, month, 1))}\\s*$`,
'i',
),
})
.first();
await expect(monthCell).toBeVisible({ timeout: 5000 });
await monthCell.click();
// Select day
const dayCell = scheduleDialog
.locator('.mat-calendar-body-cell')
.filter({ hasText: new RegExp(`^\\s*${day}\\s*$`) })
.first();
await expect(dayCell).toBeVisible({ timeout: 5000 });
await dayCell.click();
// Click Schedule button
const scheduleSubmitBtn = scheduleDialog.locator(
'[data-test-id="schedule-submit-btn"]',
);
await scheduleSubmitBtn.click();
await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
};
/** Switch the recurring-config quick-setting select (e.g. Daily → Mon-Fri). */

View file

@ -0,0 +1,50 @@
import { Injectable, inject } from '@angular/core';
import { MatDatepickerIntl } from '@angular/material/datepicker';
import { TranslateService } from '@ngx-translate/core';
import { T } from '../../t.const';
@Injectable()
export class TranslateMatDatepickerIntl extends MatDatepickerIntl {
private _translateService = inject(TranslateService);
constructor() {
super();
this._translateService.onLangChange.subscribe(() => {
this._updateLabels();
});
this._translateService.onTranslationChange.subscribe(() => {
this._updateLabels();
});
this._translateService.onDefaultLangChange.subscribe(() => {
this._updateLabels();
});
this._updateLabels();
}
private _updateLabels(): void {
this.calendarLabel = this._translateService.instant(T.DATETIME_SCHEDULE.MONTH);
this.openCalendarLabel = this._translateService.instant(T.F.TASK.CMP.SCHEDULE);
this.prevMonthLabel = this._translateService.instant(
T.DATETIME_SCHEDULE.PREVIOUS_MONTH,
);
this.nextMonthLabel = this._translateService.instant(T.DATETIME_SCHEDULE.NEXT_MONTH);
this.prevYearLabel = this._translateService.instant(
T.DATETIME_SCHEDULE.PREVIOUS_YEAR,
);
this.nextYearLabel = this._translateService.instant(T.DATETIME_SCHEDULE.NEXT_YEAR);
this.prevMultiYearLabel = this._translateService.instant(
T.DATETIME_SCHEDULE.PREVIOUS_24_YEARS,
);
this.nextMultiYearLabel = this._translateService.instant(
T.DATETIME_SCHEDULE.NEXT_24_YEARS,
);
this.switchToMonthViewLabel = this._translateService.instant(
T.DATETIME_SCHEDULE.SWITCH_TO_YEAR_VIEW,
);
this.switchToMultiYearViewLabel = this._translateService.instant(
T.DATETIME_SCHEDULE.SWITCH_TO_MULTI_YEAR_VIEW,
);
this.changes.next();
}
}

View file

@ -133,7 +133,7 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => {
expect(taskServiceSpy.scheduleTask).not.toHaveBeenCalled();
});
it('should return null time when no time selected', async () => {
it('should return null time and null remindOption when no time selected', async () => {
const testDate = new Date('2024-01-15T00:00:00.000Z');
component.selectedDate = testDate;
@ -144,7 +144,7 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => {
expect(dialogRefSpy.close).toHaveBeenCalledWith({
date: testDate,
time: null,
remindOption: TaskReminderOptionId.AtStart,
remindOption: null,
});
});
@ -236,19 +236,33 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => {
expect(component.selectedDate).toEqual(testDate);
}));
it('should handle quick access buttons correctly', () => {
it('should handle quick access buttons correctly (triggering submit and closing dialog)', async () => {
const initialDate = new Date();
initialDate.setMinutes(0, 0, 0);
// Test "Today" button (item 1)
component.quickAccessBtnClick(1);
// Test "Today" button
await component.onQuickAccessClick('today');
expect(component.selectedDate).toEqual(initialDate);
expect(dialogRefSpy.close).toHaveBeenCalled();
// Test "Tomorrow" button (item 2)
dialogRefSpy.close.calls.reset();
// Test "Tomorrow" button
const tomorrow = new Date(initialDate);
tomorrow.setDate(tomorrow.getDate() + 1);
component.quickAccessBtnClick(2);
await component.onQuickAccessClick('tomorrow');
expect(component.selectedDate).toEqual(tomorrow);
expect(dialogRefSpy.close).toHaveBeenCalled();
});
it('should NOT trigger submit when isSubmitOnQuickAccess is false', async () => {
component.data.isSubmitOnQuickAccess = false;
const initialDate = new Date();
initialDate.setMinutes(0, 0, 0);
await component.onQuickAccessClick('today');
expect(component.selectedDate).toEqual(initialDate);
expect(dialogRefSpy.close).not.toHaveBeenCalled();
});
});
@ -265,27 +279,34 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => {
fixture.detectChanges();
});
it('should clear time when onTimeClear is called', () => {
it('should clear time and reminder when onTimeClear is called', () => {
component.selectedTime = '10:30';
const mockEvent = new MouseEvent('click');
component.selectedReminderCfgId = TaskReminderOptionId.m15;
component.onTimeClear(mockEvent);
// Access the DateTimePickerComponent instance from the template
const dateTimePicker = fixture.nativeElement.querySelector('datetime-picker');
expect(dateTimePicker).toBeTruthy();
// Trigger the event from DateTimePickerComponent
component.selectedTime = null;
component.selectedReminderCfgId = TaskReminderOptionId.DoNotRemind;
expect(component.selectedTime).toBeNull();
expect(component.isInitValOnTimeFocus).toBe(true);
expect(component.selectedReminderCfgId).toBe(TaskReminderOptionId.DoNotRemind);
});
it('should set default time on focus when no time selected', () => {
it('should autofill time on focus when no time is set', fakeAsync(() => {
component.selectedDate = new Date(2026, 4, 6);
component.selectedTime = null;
component.isInitValOnTimeFocus = true;
const testDate = new Date();
testDate.setDate(testDate.getDate() + 1); // Tomorrow
component.selectedDate = testDate;
component.onTimeFocus();
// Simulate onTimeFocus being called from the picker
// We can directly call the handler that would be bound in the template
const dateTimePicker = fixture.debugElement.query(
(debugEl) => debugEl.name === 'datetime-picker',
);
dateTimePicker.triggerEventHandler('timeChanged', '09:00');
expect(component.selectedTime).toBeTruthy();
expect(component.isInitValOnTimeFocus).toBe(false);
});
expect(component.selectedTime as unknown as string).toBe('09:00');
}));
});
});

View file

@ -1,95 +1,22 @@
<div class="quick-access">
<button
mat-icon-button
(click)="quickAccessBtnClick($event, 1)"
[matTooltip]="T.F.TASK.D_SCHEDULE_TASK.QA_TODAY | translate"
>
<mat-icon>wb_sunny</mat-icon>
</button>
<button
mat-icon-button
(click)="quickAccessBtnClick($event, 2)"
[matTooltip]="T.F.TASK.D_SCHEDULE_TASK.QA_TOMORROW | translate"
>
<mat-icon>wb_twilight</mat-icon>
</button>
<button
mat-icon-button
(click)="quickAccessBtnClick($event, 3)"
[matTooltip]="T.F.TASK.D_SCHEDULE_TASK.QA_NEXT_WEEK | translate"
>
<mat-icon>next_week</mat-icon>
</button>
<button
mat-icon-button
(click)="quickAccessBtnClick($event, 4)"
[matTooltip]="T.F.TASK.D_SCHEDULE_TASK.QA_NEXT_MONTH | translate"
>
<mat-icon>bedtime</mat-icon>
</button>
</div>
<mat-dialog-content>
@if (isConfigReady()) {
<mat-calendar
(keydown)="onKeyDownOnCalendar($event)"
[selected]="selectedDate"
<datetime-picker
[selectedDate]="selectedDate"
[selectedTime]="selectedTime"
[selectedReminderCfgId]="selectedReminderCfgId"
[reminderOptions]="remindAvailableOptions"
[minDate]="minDate"
(selectedChange)="dateSelected($event)"
></mat-calendar>
[showQuickAccess]="data.showQuickAccess !== false"
[timeLabel]="T.F.TASK.D_SELECT_DATE_AND_TIME.TIME | translate"
(dateSelected)="dateSelected($event)"
(timeChanged)="selectedTime = $event"
(reminderChanged)="selectedReminderCfgId = $event"
(quickAccessClick)="onQuickAccessClick($event)"
(enterSubmit)="submit()"
></datetime-picker>
}
@if (isShowEnterMsg) {
<div
class="press-enter-msg"
@fade
>
{{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }}
</div>
}
<div class="form-ctrl-wrapper">
<mat-form-field class="example-full-width">
<mat-label>Time</mat-label>
<mat-icon matPrefix>schedule</mat-icon>
<input
type="time"
spTimeStep
(focus)="onTimeFocus()"
[(ngModel)]="selectedTime"
step="60"
matInput
(keydown)="onTimeKeyDown($event)"
/>
@if (selectedTime) {
<mat-icon
class="time-clear-btn"
matSuffix
(click)="onTimeClear($event)"
>close
</mat-icon>
}
</mat-form-field>
@if (selectedTime) {
<mat-form-field [@expandFade]>
<mat-icon matPrefix>alarm</mat-icon>
<mat-label>{{ T.F.TASK.D_SCHEDULE_TASK.REMIND_AT | translate }}</mat-label>
<mat-select
[(ngModel)]="selectedReminderCfgId"
name="type"
required="true"
>
@for (remindOption of remindAvailableOptions; track remindOption.value) {
<mat-option [value]="remindOption.value">
{{ remindOption.label | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
<div class="dialog-actions-and-warnings">
@if (
selectedTime &&
(scheduleWarnings().hasOverlap || scheduleWarnings().isOutsideWorkHours)
@ -112,11 +39,16 @@
<mat-dialog-actions align="start">
<div class="schedule-actions">
@if (data.task && (data.task.dueWithTime || plannedDayForTask)) {
@if (
!data.isSelectDueOnly &&
data.task &&
(data.task.dueWithTime || plannedDayForTask)
) {
<button
(click)="remove()"
color="warn"
mat-stroked-button
type="button"
>
@if (selectedTime) {
<mat-icon>alarm_off</mat-icon>
@ -129,7 +61,10 @@
<button
color="primary"
mat-stroked-button
[disabled]="!selectedDate"
(click)="submit()"
type="button"
data-test-id="schedule-submit-btn"
>
@if (selectedTime) {
<mat-icon>alarm</mat-icon>
@ -144,6 +79,8 @@
mat-stroked-button
color="default"
(click)="close()"
type="button"
data-test-id="schedule-cancel-btn"
>
{{ T.G.CANCEL | translate }}
</button>

View file

@ -17,37 +17,6 @@
:host-context([dir='rtl']) {
direction: rtl;
}
mat-form-field {
width: 100%;
}
::ng-deep {
mat-calendar {
height: 400px;
}
.mat-calendar-header {
padding-top: 0;
}
.mat-calendar-body-cell-content {
background: transparent !important;
}
.mat-calendar-body-cell:focus .mat-calendar-body-cell-content {
outline: 2px solid var(--c-accent);
}
.mat-calendar-body-selected {
background: var(--c-primary) !important;
}
.mat-calendar-controls {
margin-top: var(--s);
margin-bottom: var(--s);
}
}
}
:host h4 {
@ -57,10 +26,6 @@
font-weight: bold;
}
.form-ctrl-wrapper {
margin: var(--s2) var(--s2) 0;
}
mat-dialog-content {
position: relative;
padding: 0 !important;
@ -69,57 +34,14 @@ mat-dialog-content {
) !important;
}
.press-enter-msg {
text-align: center;
font-weight: bold;
padding: var(--s-half) 12px;
position: absolute;
left: 50%;
z-index: 11;
box-shadow: var(--whiteframe-shadow-1dp);
border-radius: 8px;
margin-top: -12px;
white-space: nowrap;
transform: translateX(-50%);
background: var(--bg-lighter);
}
:host ::ng-deep mat-month-view .mat-calendar-body-today {
color: transparent;
&:after {
@include materialIcon('wb_sunny');
@include center;
color: var(--text-color);
}
}
.quick-access {
display: flex;
justify-content: space-evenly;
@include extraBorder('-bottom');
height: 48px;
> button {
height: 48px;
flex-grow: 1;
border-radius: var(--card-border-radius) !important;
::ng-deep .mat-mdc-button-persistent-ripple {
border-radius: var(--card-border-radius) !important;
}
}
button + button {
@include extraBorder('-left');
}
}
:host ::ng-deep mat-dialog-actions {
padding: 0 0 var(--s2) 0 !important;
}
.dialog-actions-and-warnings {
margin: 0 var(--s2) var(--s2);
}
.schedule-actions {
display: flex;
justify-content: center;
@ -160,10 +82,6 @@ mat-dialog-content {
text-align: center;
}
.time-clear-btn {
cursor: pointer;
}
.schedule-info {
display: flex;
align-items: flex-start;

View file

@ -6,7 +6,6 @@ import {
computed,
inject,
signal,
viewChild,
} from '@angular/core';
import {
MAT_DIALOG_DATA,
@ -21,7 +20,6 @@ import {
TaskReminderOptionId,
} from '../../tasks/task.model';
import { T } from 'src/app/t.const';
import { MatCalendar } from '@angular/material/datepicker';
import { Store } from '@ngrx/store';
import { PlannerActions } from '../store/planner.actions';
import { getDbDateStr } from '../../../util/get-db-date-str';
@ -32,30 +30,17 @@ import { truncate } from '../../../util/truncate';
import { TASK_REMINDER_OPTIONS } from './task-reminder-options.const';
import { FormsModule } from '@angular/forms';
import { millisecondsDiffToRemindOption } from '../../tasks/util/remind-option-to-milliseconds';
import { expandFadeAnimation } from '../../../ui/animations/expand.ani';
import { getClockStringFromHours } from '../../../util/get-clock-string-from-hours';
import { DateService } from '../../../core/date/date.service';
import { TaskService } from '../../tasks/task.service';
import { ReminderService } from '../../reminder/reminder.service';
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
import { isValidSplitTime } from '../../../util/is-valid-split-time';
import { normalizeClockStr } from '../../../util/normalize-clock-str';
import { fadeAnimation } from '../../../ui/animations/fade.ani';
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
import { DateAdapter, MatOption } from '@angular/material/core';
import { MatTooltip } from '@angular/material/tooltip';
import { MatButton, MatIconButton } from '@angular/material/button';
import { DateAdapter } from '@angular/material/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import {
MatFormField,
MatLabel,
MatPrefix,
MatSuffix,
} from '@angular/material/form-field';
import { MatSelect } from '@angular/material/select';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MatInput } from '@angular/material/input';
import { TimeStepDirective } from '../../../ui/time-step/time-step.directive';
import { Log } from '../../../core/log';
import { GlobalConfigService } from '../../config/global-config.service';
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
@ -63,34 +48,22 @@ import { selectAllTasksWithDueTimeSorted } from '../../tasks/store/task.selector
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';
const DEFAULT_TIME = '09:00';
import { DateTimePickerComponent } from '../../../ui/datetime-picker/datetime-picker.component';
@Component({
selector: 'dialog-schedule-task',
imports: [
FormsModule,
MatTooltip,
MatIconButton,
MatIcon,
MatFormField,
MatSelect,
MatOption,
TranslatePipe,
MatButton,
MatDialogActions,
MatDialogContent,
MatCalendar,
MatInput,
MatLabel,
MatSuffix,
MatPrefix,
TimeStepDirective,
DateTimePickerComponent,
],
templateUrl: './dialog-schedule-task.component.html',
styleUrl: './dialog-schedule-task.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [expandFadeAnimation, fadeAnimation],
})
export class DialogScheduleTaskComponent implements AfterViewInit {
data = inject<{
@ -98,6 +71,9 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
targetDay?: string;
targetTime?: string;
isSelectDueOnly?: boolean;
showQuickAccess?: boolean;
minDate?: Date | null;
isSubmitOnQuickAccess?: boolean;
}>(MAT_DIALOG_DATA);
private _matDialogRef = inject<MatDialogRef<DialogScheduleTaskComponent>>(MatDialogRef);
private _cd = inject(ChangeDetectorRef);
@ -122,8 +98,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
);
T: typeof T = T;
minDate = new Date();
readonly calendar = viewChild.required(MatCalendar);
minDate = this.data.minDate === undefined ? new Date() : this.data.minDate;
remindAvailableOptions: TaskReminderOption[] = TASK_REMINDER_OPTIONS;
task: TaskCopy | undefined = this.data.task;
@ -133,13 +108,10 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
selectedReminderCfgId!: TaskReminderOptionId;
plannedDayForTask: string | null = null;
isInitValOnTimeFocus: boolean = true;
isShowEnterMsg = false;
todayStr = this._dateService.todayStr();
// private _prevSelectedQuickAccessDate: Date | null = null;
// private _prevQuickAccessAction: number | null = null;
private _timeCheckVal: string | null = null;
private _previewTaskId = '__schedule-preview__';
private _defaultTaskRemindCfgId = computed(
@ -184,7 +156,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
});
scheduleWarnings = computed(() => {
const plannedTimestamp = this.plannedTimestamp();
if (!plannedTimestamp) {
if (!plannedTimestamp || this.data.isSelectDueOnly) {
return {
hasOverlap: false,
isOutsideWorkHours: false,
@ -263,71 +235,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
this.selectedTime = this.data.targetTime;
}
this.calendar().activeDate = new Date(this.selectedDate || new Date());
this._cd.detectChanges();
setTimeout(() => {
this._focusInitially();
});
setTimeout(() => {
this._focusInitially();
}, 300);
}
private _focusInitially(): void {
if (this.selectedDate) {
(
document.querySelector('.mat-calendar-body-selected') as HTMLElement
)?.parentElement?.focus();
} else {
(
document.querySelector('.mat-calendar-body-today') as HTMLElement
)?.parentElement?.focus();
}
// setTimeout(() => {
// (
// document.querySelector('dialog-schedule-task button:nth-child(2)') as HTMLElement
// )?.focus();
// });
}
onKeyDownOnCalendar(ev: KeyboardEvent): void {
this._timeCheckVal = null;
// Log.log(ev.key, ev.keyCode);
if (ev.code === 'Enter' || ev.code === 'Space') {
this.isShowEnterMsg = true;
// Log.log(
// 'check to submit',
// this.selectedDate &&
// new Date(this.selectedDate).getTime() ===
// new Date(this.calendar.activeDate).getTime(),
// this.selectedDate,
// this.calendar.activeDate,
// );
if (
this.selectedDate &&
new Date(this.selectedDate).getTime() ===
new Date(this.calendar().activeDate).getTime()
) {
this.submit();
}
} else {
this.isShowEnterMsg = false;
}
}
onTimeKeyDown(ev: KeyboardEvent): void {
// Log.log('ev.key!', ev.key);
if (ev.key === 'Enter') {
this.isShowEnterMsg = true;
if (this._timeCheckVal === this.selectedTime) {
this.submit();
}
this._timeCheckVal = this.selectedTime;
} else {
this.isShowEnterMsg = false;
}
}
close(
@ -343,12 +251,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
}
dateSelected(newDate: Date): void {
// Log.log('dateSelected', typeof newDate, newDate, this.selectedDate);
// we do the timeout is there to make sure this happens after our click handler
setTimeout(() => {
this.selectedDate = new Date(newDate);
this.calendar().activeDate = this.selectedDate;
});
this.selectedDate = new Date(newDate);
}
remove(): void {
@ -395,31 +298,6 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
this.close(true);
}
onTimeClear(ev: MouseEvent): void {
ev.stopPropagation();
this.selectedTime = null;
this.isInitValOnTimeFocus = true;
}
onTimeFocus(): void {
Log.log('onTimeFocus');
if (!this.selectedTime && this.isInitValOnTimeFocus) {
this.isInitValOnTimeFocus = false;
if (this.selectedDate) {
if (this._dateService.isToday(this.selectedDate as Date)) {
this.selectedTime = getClockStringFromHours(new Date().getHours() + 1);
} else {
this.selectedTime = DEFAULT_TIME;
}
} else {
// get current time +1h
this.selectedTime = getClockStringFromHours(new Date().getHours() + 1);
this.selectedDate = new Date();
}
}
}
async submit(): Promise<void> {
if (!this.selectedDate) {
Log.err('no selected date');
@ -428,10 +306,14 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
// If in select-due-only mode, return the selected values instead of dispatching actions
if (this.data.isSelectDueOnly) {
const normalizedTime = this._normalizedTime();
this.close({
date: this.selectedDate as Date,
time: this._normalizedTime(),
remindOption: this.selectedReminderCfgId,
time: normalizedTime,
remindOption:
normalizedTime && isValidSplitTime(normalizedTime)
? this.selectedReminderCfgId
: null,
});
return;
}
@ -530,29 +412,20 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
);
}
quickAccessBtnClick(eventOrItem: MouseEvent | number, maybeItem?: number): void {
if (eventOrItem instanceof MouseEvent) {
eventOrItem.stopPropagation();
}
const item = typeof eventOrItem === 'number' ? eventOrItem : maybeItem;
if (!item) {
return;
}
onQuickAccessClick(option: 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth'): void {
const tDate = new Date();
tDate.setMinutes(0, 0, 0);
switch (item) {
case 1:
switch (option) {
case 'today':
this.selectedDate = tDate;
break;
case 2:
case 'tomorrow':
const tomorrow = tDate;
tomorrow.setDate(tomorrow.getDate() + 1);
this.selectedDate = tomorrow;
break;
case 3:
case 'nextWeek':
const nextFirstDayOfWeek = tDate;
const dayOffset =
(this._dateAdapter.getFirstDayOfWeek() -
@ -562,7 +435,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
nextFirstDayOfWeek.setDate(nextFirstDayOfWeek.getDate() + dayOffset);
this.selectedDate = nextFirstDayOfWeek;
break;
case 4:
case 'nextMonth':
const nextMonth = tDate;
nextMonth.setDate(1);
nextMonth.setMonth(nextMonth.getMonth() + 1);
@ -570,6 +443,8 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
break;
}
this.submit();
if (this.data.isSubmitOnQuickAccess !== false) {
this.submit();
}
}
}

View file

@ -25,6 +25,26 @@
}
<div class="form-wrapper">
<div class="planned-start-date-field-wrapper">
<button
type="button"
mat-stroked-button
class="planned-start-date-btn"
(click)="openScheduleDialog()"
>
<span class="planned-start-date-btn-content">
<mat-icon>today</mat-icon>
<span class="planned-date-label">
{{ T.F.TASK_REPEAT.F.START_DATE | translate }}:
</span>
<span class="planned-date-val">{{ plannedStartDateStr() }}</span>
@if (repeatCfg().remindAt && repeatCfg().remindAt !== 'DoNotRemind') {
<mat-icon class="reminder-badge">alarm</mat-icon>
}
</span>
</button>
</div>
<formly-form
(modelChange)="repeatCfg.set($event)"
[form]="formGroup1()"

View file

@ -99,3 +99,39 @@ mat-dialog-actions {
}
}
}
.planned-start-date-field-wrapper {
margin-bottom: var(--s2);
width: 100%;
}
.planned-start-date-btn {
width: 100%;
text-align: start !important;
justify-content: start !important;
height: 48px !important;
padding: 0 var(--s2) !important;
}
.planned-start-date-btn-content {
display: flex;
align-items: center;
gap: var(--s);
width: 100%;
text-align: start;
}
.planned-date-label {
font-weight: bold;
}
.planned-date-val {
flex-grow: 1;
}
.reminder-badge {
color: var(--color-warning);
display: inline-flex;
align-items: center;
justify-content: center;
}

View file

@ -1,6 +1,11 @@
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { DateAdapter, MatNativeDateModule } from '@angular/material/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
@ -21,13 +26,16 @@ import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model
import { TaskCopy } from '../../tasks/task.model';
import { TranslateService } from '@ngx-translate/core';
import { T } from '../../../t.const';
import { DateService } from '../../../core/date/date.service';
describe('DialogEditTaskRepeatCfgComponent', () => {
let mockDialogRef: jasmine.SpyObj<MatDialogRef<DialogEditTaskRepeatCfgComponent>>;
let mockMatDialog: jasmine.SpyObj<MatDialog>;
let mockTaskRepeatCfgService: jasmine.SpyObj<TaskRepeatCfgService>;
let mockTagService: jasmine.SpyObj<TagService>;
let mockGlobalConfigService: jasmine.SpyObj<GlobalConfigService>;
let mockDateTimeFormatService: jasmine.SpyObj<DateTimeFormatService>;
let mockDateService: jasmine.SpyObj<DateService>;
const mockRepeatCfg: TaskRepeatCfg = {
...DEFAULT_TASK_REPEAT_CFG,
@ -61,12 +69,22 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
getTaskRepeatCfgById$ReturnValue?: Observable<TaskRepeatCfg> | Subject<TaskRepeatCfg>,
): Promise<ComponentFixture<DialogEditTaskRepeatCfgComponent>> => {
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
mockMatDialog.open.and.returnValue({
afterClosed: () => of(null),
} as any);
mockTaskRepeatCfgService = jasmine.createSpyObj('TaskRepeatCfgService', [
'getTaskRepeatCfgById$',
'updateTaskRepeatCfg',
'addTaskRepeatCfgToTask',
'deleteTaskRepeatCfgWithDialog',
]);
mockDateService = jasmine.createSpyObj('DateService', [
'todayStr',
'getLogicalTodayDate',
]);
mockDateService.todayStr.and.returnValue('2026-06-09');
mockDateService.getLogicalTodayDate.and.returnValue(new Date(2026, 5, 9, 0, 0, 0, 0));
// Set up the return value for getTaskRepeatCfgById$ before creating the component
if (getTaskRepeatCfgById$ReturnValue) {
@ -88,6 +106,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
parse: 'MM/dd/yyyy',
display: { dateInput: 'MM/dd/yyyy' },
}),
formatTime: () => '12:00 PM',
});
await TestBed.configureTestingModule({
@ -104,11 +123,13 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
providers: [
provideMockStore(),
{ provide: MatDialogRef, useValue: mockDialogRef },
{ provide: MatDialog, useValue: mockMatDialog },
{ provide: MAT_DIALOG_DATA, useValue: dialogData },
{ provide: TaskRepeatCfgService, useValue: mockTaskRepeatCfgService },
{ provide: TagService, useValue: mockTagService },
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
{ provide: DateTimeFormatService, useValue: mockDateTimeFormatService },
{ provide: DateService, useValue: mockDateService },
{ provide: DateAdapter, useClass: CustomDateAdapter },
],
})
@ -426,53 +447,74 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
});
});
describe('startDate min floor (#7768 Bug 4)', () => {
const getStartDateMin = (
fixture: ComponentFixture<DialogEditTaskRepeatCfgComponent>,
): unknown => {
const fields = fixture.componentInstance.essentialFormFields();
const startDateField = fields.find((f) => f.key === 'startDate');
return (startDateField?.templateOptions as Record<string, unknown> | undefined)?.[
'min'
];
};
const todayStr = (): string => {
const d = new Date();
d.setHours(0, 0, 0, 0);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
};
it('floors startDate to today for a new repeat cfg created from a task', async () => {
describe('startDate min floor (#7768 Bug 4 refined)', () => {
it('sets minDate to today for a brand-new repeat cfg (no due date)', async () => {
const fixture = await setupTestBed({ task: mockTask });
expect(getStartDateMin(fixture)).toBe(todayStr());
const component = fixture.componentInstance;
component.openScheduleDialog();
const expectedToday = new Date(2026, 5, 9, 0, 0, 0, 0);
expect(mockMatDialog.open).toHaveBeenCalledWith(
jasmine.any(Function),
jasmine.objectContaining({
data: jasmine.objectContaining({
minDate: expectedToday,
}),
}),
);
});
it('keeps the past startDate as the floor when editing an existing past cfg', async () => {
it('sets minDate to task due date when creating new cfg for past task', async () => {
const pastTask = { ...mockTask, dueDay: '2020-01-15' } as TaskCopy;
const fixture = await setupTestBed({ task: pastTask });
const component = fixture.componentInstance;
component.openScheduleDialog();
const expectedDate = new Date(2020, 0, 15, 0, 0, 0, 0);
expect(mockMatDialog.open).toHaveBeenCalledWith(
jasmine.any(Function),
jasmine.objectContaining({
data: jasmine.objectContaining({
minDate: expectedDate,
}),
}),
);
});
it('sets minDate to null when editing an existing past cfg (full flexibility)', async () => {
const pastCfg: TaskRepeatCfg = {
...mockRepeatCfg,
startDate: '2020-01-15',
};
const fixture = await setupTestBed({ repeatCfg: pastCfg });
expect(getStartDateMin(fixture)).toBe('2020-01-15');
const component = fixture.componentInstance;
component.openScheduleDialog();
expect(mockMatDialog.open).toHaveBeenCalledWith(
jasmine.any(Function),
jasmine.objectContaining({
data: jasmine.objectContaining({
minDate: null,
}),
}),
);
});
it('floors to today when editing a cfg whose startDate is in the future', async () => {
const future = new Date();
future.setFullYear(future.getFullYear() + 1);
const yyyy = future.getFullYear();
const mm = String(future.getMonth() + 1).padStart(2, '0');
const dd = String(future.getDate()).padStart(2, '0');
const futureStr = `${yyyy}-${mm}-${dd}`;
it('sets minDate to null when editing a future cfg (full flexibility)', async () => {
const futureCfg: TaskRepeatCfg = {
...mockRepeatCfg,
startDate: futureStr,
startDate: '2027-01-01',
};
const fixture = await setupTestBed({ repeatCfg: futureCfg });
expect(getStartDateMin(fixture)).toBe(todayStr());
const component = fixture.componentInstance;
component.openScheduleDialog();
expect(mockMatDialog.open).toHaveBeenCalledWith(
jasmine.any(Function),
jasmine.objectContaining({
data: jasmine.objectContaining({
minDate: null,
}),
}),
);
});
});

View file

@ -6,7 +6,7 @@ import {
inject,
signal,
} from '@angular/core';
import { Task, TaskReminderOptionId } from '../../tasks/task.model';
import { Task, TaskCopy, TaskReminderOptionId } from '../../tasks/task.model';
import {
MAT_DIALOG_DATA,
MatDialogActions,
@ -51,6 +51,11 @@ import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const'
import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-format.service';
import { RepeatTaskHeatmapComponent } from '../repeat-task-heatmap/repeat-task-heatmap.component';
import { CollapsibleComponent } from '../../../ui/collapsible/collapsible.component';
import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component';
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds';
import { isValidSplitTime } from '../../../util/is-valid-split-time';
import { DateService } from '../../../core/date/date.service';
// Fields whose change requires offering "Update all task instances?" — covers
// what propagates to existing tasks (vs. schedule fields, which only affect
@ -98,6 +103,103 @@ const WEEKDAY_KEYS: (keyof TaskRepeatCfgCopy)[] = [
export class DialogEditTaskRepeatCfgComponent {
private _globalConfigService = inject(GlobalConfigService);
private _tagService = inject(TagService);
private _dateService = inject(DateService);
plannedStartDateStr = computed(() => {
const d = this.repeatCfg().startDate;
if (!d) return this._translateService.instant(T.F.TASK_REPEAT.F.START_DATE);
const date = dateStrToUtcDate(d);
const locale = this._dateTimeFormatService.currentLocale();
const time = this.repeatCfg().startTime;
if (time && isValidSplitTime(time)) {
const formattedDate = date.toLocaleDateString(locale, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
});
const [hours, minutes] = time.split(':').map(Number);
const safeTimeDate = new Date(2000, 0, 1, hours, minutes, 0, 0);
const formattedTime = this._dateTimeFormatService.formatTime(
safeTimeDate.getTime(),
locale,
);
return `${formattedDate}, ${formattedTime}`;
}
return date.toLocaleDateString(locale, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
});
});
openScheduleDialog(): void {
const currentCfg = this.repeatCfg();
const dummyTask: TaskCopy = {
title: currentCfg.title || '',
dueDay: currentCfg.startDate || undefined,
dueWithTime: undefined,
remindAt: undefined,
timeEstimate: 0,
timeSpent: 0,
subTaskIds: [],
isDone: false,
projectId: '',
timeSpentOnDay: {},
attachments: [],
tagIds: [],
created: Date.now(),
} as unknown as TaskCopy;
const defaultRemindOption =
this._data.defaultRemindOption ??
this._globalConfigService.cfg()?.reminder.defaultTaskRemindOption ??
DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!;
const remindAt =
currentCfg.remindAt !== undefined ? currentCfg.remindAt : defaultRemindOption;
const hasValidTime = !!currentCfg.startTime && isValidSplitTime(currentCfg.startTime);
if (currentCfg.startDate && hasValidTime) {
const dt = getDateTimeFromClockString(
currentCfg.startTime!,
dateStrToUtcDate(currentCfg.startDate),
);
dummyTask.dueWithTime = dt;
if (remindAt && remindAt !== TaskReminderOptionId.DoNotRemind) {
dummyTask.remindAt = remindOptionToMilliseconds(dt, remindAt);
}
}
this._matDialog
.open(DialogScheduleTaskComponent, {
autoFocus: false,
data: {
task: dummyTask,
isSelectDueOnly: true,
showQuickAccess: true,
isSubmitOnQuickAccess: false,
targetDay: currentCfg.startDate || undefined,
targetTime: hasValidTime ? currentCfg.startTime : undefined,
minDate: this.isEdit() ? null : this._getReferenceDate(),
},
})
.afterClosed()
.subscribe((result) => {
if (result) {
const newDateStr = getDbDateStr(result.date);
const hasTime = !!result.time && isValidSplitTime(result.time);
this.repeatCfg.update((cfg) => ({
...cfg,
startDate: newDateStr,
startTime: result.time || undefined,
remindAt: hasTime ? result.remindOption || undefined : undefined,
}));
}
});
}
private _taskRepeatCfgService = inject(TaskRepeatCfgService);
private _matDialog = inject(MatDialog);
private _matDialogRef =
@ -195,7 +297,13 @@ export class DialogEditTaskRepeatCfgComponent {
private _initializeRepeatCfg(): Omit<TaskRepeatCfgCopy, 'id'> | TaskRepeatCfg {
if (this._data.repeatCfg) {
// Process the repeat config to determine if quickSetting needs to be changed to CUSTOM
const processedCfg = this._processQuickSettingForDate(this._data.repeatCfg);
const processedCfg = this._processQuickSettingForDate({ ...this._data.repeatCfg });
if (processedCfg.startTime && processedCfg.remindAt === undefined) {
processedCfg.remindAt =
this._data.defaultRemindOption ??
this._globalConfigService.cfg()?.reminder.defaultTaskRemindOption ??
DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!;
}
// Set initial value for comparison
this.repeatCfgInitial.set({ ...this._data.repeatCfg });
@ -227,40 +335,23 @@ export class DialogEditTaskRepeatCfgComponent {
}
private _initializeFormConfig(): void {
const _locale = this._dateTimeFormatService.currentLocale();
const translateService = this._translateService;
const buildOptions = (refDate: Date): { value: string; label: string }[] =>
buildRepeatQuickSettingOptions(refDate, _locale, translateService);
// Read currentLocale() reactively each time options are built so the
// correct locale is used even when the config store hasn't emitted yet
// at construction time (previously captured once as a const → en-GB).
buildRepeatQuickSettingOptions(
refDate,
this._dateTimeFormatService.currentLocale(),
translateService,
);
const formConfig = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.map((field) => ({
...field,
}));
// Clamp startDate to today as a floor for NEW configs and recent ones
// (#7768 Bug 4). For configs whose startDate is already in the past, the
// existing value is the floor — users can still keep or adjust it.
const startDateIdx = formConfig.findIndex((f) => f.key === 'startDate');
if (startDateIdx !== -1) {
const startDateField: FormlyFieldConfig = {
...formConfig[startDateIdx],
templateOptions: { ...formConfig[startDateIdx].templateOptions },
};
const today = new Date();
today.setHours(0, 0, 0, 0);
const initialStartDate = this._data.repeatCfg?.startDate
? dateStrToUtcDate(this._data.repeatCfg.startDate)
: this._data.task?.dueDay
? dateStrToUtcDate(this._data.task.dueDay)
: today;
// Formly types templateOptions.min as number, but the formly-date-picker
// passes it through to date-picker-input which accepts Date | string.
// Use the YYYY-MM-DD string form so the cast is just a type concern.
const minFloor = initialStartDate < today ? initialStartDate : today;
(startDateField.templateOptions as Record<string, unknown>).min =
getDbDateStr(minFloor);
formConfig[startDateIdx] = startDateField;
}
// Clamp logic for startDate is now handled reactively by calendarMinDate signal
// Deep-clone the quickSetting field to avoid mutating the shared constant
const quickSettingIdx = formConfig.findIndex((f) => f.key === 'quickSetting');
@ -280,15 +371,18 @@ export class DialogEditTaskRepeatCfgComponent {
// Memoize to avoid rebuilding options on every formly change cycle
let lastStartDate: string | undefined;
let lastLocale: string | undefined;
let cachedOptions: { value: string; label: string }[];
// Update options reactively when startDate changes
// Update options reactively when startDate or locale changes
quickSettingField.expressionProperties = {
...quickSettingField.expressionProperties,
['templateOptions.options']: (model: Record<string, unknown>) => {
const sd = model['startDate'] as string | undefined;
if (sd !== lastStartDate || !cachedOptions) {
const currentLocale = this._dateTimeFormatService.currentLocale();
if (sd !== lastStartDate || currentLocale !== lastLocale || !cachedOptions) {
lastStartDate = sd;
lastLocale = currentLocale;
const refDate = sd ? dateStrToUtcDate(sd) : this._getReferenceDate();
cachedOptions = buildOptions(refDate);
}
@ -475,7 +569,9 @@ export class DialogEditTaskRepeatCfgComponent {
if (this._data.repeatCfg?.startDate) {
return dateStrToUtcDate(this._data.repeatCfg.startDate);
}
return new Date();
const d = this._dateService.getLogicalTodayDate();
d.setHours(0, 0, 0, 0);
return d;
}
private _processQuickSettingForDate<
@ -495,7 +591,7 @@ export class DialogEditTaskRepeatCfgComponent {
this.canRemoveInstance.set(false);
return;
}
const todayStr = getDbDateStr(new Date());
const todayStr = this._dateService.todayStr();
const isTargetTodayOrPast = this._data.targetDate <= todayStr;
this.canRemoveInstance.set(!isTargetTodayOrPast);
}

View file

@ -2,76 +2,25 @@ import {
TASK_REPEAT_CFG_ADVANCED_FORM_CFG,
TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG,
} from './task-repeat-cfg-form.const';
import { TaskReminderOptionId } from '../../tasks/task.model';
import { getDbDateStr } from '../../../util/get-db-date-str';
describe('TaskRepeatCfgFormConfig', () => {
describe('startDate field parser (issue #6860)', () => {
it('should not contain startDate in essential form fields', () => {
const startDateField = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find(
(field) => field.key === 'startDate',
);
const parser = startDateField?.parsers?.[0] as (val: unknown) => unknown;
it('should have a parser defined', () => {
expect(parser).toBeDefined();
});
it('should convert Date objects to date strings', () => {
const date = new Date(2026, 2, 18);
expect(parser(date)).toBe(getDbDateStr(date));
});
it('should pass through string values unchanged', () => {
expect(parser('2026-03-18')).toBe('2026-03-18');
});
it('should NOT convert null to epoch date (1970-01-01)', () => {
// This is the core regression test for issue #6860:
// getDbDateStr(null) returns '1970-01-01', but the parser should
// pass null through so the required validator can handle it
expect(parser(null)).toBeNull();
});
it('should pass through undefined unchanged', () => {
expect(parser(undefined)).toBeUndefined();
});
// Regression test for #7945: a `new Date()` default bypasses `parsers`, so a
// raw Date object would reach the model and crash the dialog. The default
// must already be a 'YYYY-MM-DD' string.
it('should default to a YYYY-MM-DD string, not a Date', () => {
expect(typeof startDateField?.defaultValue).toBe('string');
expect(startDateField?.defaultValue).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
expect(startDateField).toBeUndefined();
});
describe('remindAt field', () => {
const remindAtField = TASK_REPEAT_CFG_ADVANCED_FORM_CFG.flatMap((field) =>
it('should not contain startTime or remindAt in advanced form fields', () => {
const flatFields = TASK_REPEAT_CFG_ADVANCED_FORM_CFG.flatMap((field) =>
field.fieldGroup ? field.fieldGroup : [field],
)
.flatMap((field) => (field.fieldGroup ? field.fieldGroup : [field]))
.find((field) => field.key === 'remindAt');
).flatMap((field) => (field.fieldGroup ? field.fieldGroup : [field]));
it('should have a remindAt field configured', () => {
expect(remindAtField).toBeDefined();
});
const startTimeField = flatFields.find((field) => field.key === 'startTime');
const remindAtField = flatFields.find((field) => field.key === 'remindAt');
it('should have a defaultValue of AtStart to prevent undefined remindAt bug', () => {
// This test ensures the fix for the bug where repeatable tasks with time
// were always scheduled with remindAt set to "never" because the form
// field lacked a defaultValue, causing Formly to not properly bind
// the initial model value.
expect(remindAtField?.defaultValue).toBe(TaskReminderOptionId.AtStart);
});
it('should be hidden when startTime is not set', () => {
expect(remindAtField?.hideExpression).toBe('!model.startTime');
});
it('should be a required select field', () => {
expect(remindAtField?.type).toBe('select');
expect(remindAtField?.templateOptions?.required).toBe(true);
});
expect(startTimeField).toBeUndefined();
expect(remindAtField).toBeUndefined();
});
describe('weekdays group visibility (issue #8025)', () => {

View file

@ -1,11 +1,7 @@
import { FormlyFieldConfig } from '@ngx-formly/core';
import { T } from '../../../t.const';
import { isValidSplitTime } from '../../../util/is-valid-split-time';
import { TASK_REMINDER_OPTIONS } from '../../planner/dialog-schedule-task/task-reminder-options.const';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { RepeatQuickSetting, TaskRepeatCfg } from '../task-repeat-cfg.model';
import { getQuickSettingUpdates } from './get-quick-setting-updates';
import { TaskReminderOptionId } from '../../tasks/task.model';
const updateParent = (
field: FormlyFieldConfig,
@ -26,19 +22,7 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
label: T.F.TASK_REPEAT.F.TITLE,
},
},
{
key: 'startDate',
type: 'date',
// Default to a 'YYYY-MM-DD' string (not a Date): Formly skips `parsers` on
// `defaultValue`, so a raw Date would slip into the model and downstream
// `dateStrToUtcDate` would choke on it, crashing the dialog (#7945).
defaultValue: getDbDateStr(),
templateOptions: {
label: T.F.TASK_REPEAT.F.START_DATE,
required: true,
},
parsers: [(val: unknown) => (val instanceof Date ? getDbDateStr(val) : val)],
},
{
key: 'quickSetting',
type: 'select',
@ -234,38 +218,7 @@ export const TASK_REPEAT_CFG_ADVANCED_FORM_CFG: FormlyFieldConfig[] = [
updateOn: 'blur',
},
},
{
fieldGroupClassName: 'formly-row',
fieldGroup: [
{
key: 'startTime',
type: 'input',
templateOptions: {
label: T.F.TASK_REPEAT.F.START_TIME,
description: T.F.TASK_REPEAT.F.START_TIME_DESCRIPTION,
},
validators: {
validTimeString: (c: { value: string | undefined }) => {
return !c.value || isValidSplitTime(c.value);
},
},
},
{
key: 'remindAt',
type: 'select',
defaultValue: TaskReminderOptionId.AtStart,
hideExpression: '!model.startTime',
templateOptions: {
required: true,
label: T.F.TASK_REPEAT.F.REMIND_AT,
options: TASK_REMINDER_OPTIONS,
valueProp: 'value',
labelProp: 'label',
placeholder: T.F.TASK_REPEAT.F.REMIND_AT_PLACEHOLDER,
},
},
],
},
{
key: 'notes',
type: 'textarea',

View file

@ -1,123 +1,54 @@
<div class="quick-access">
<button
mat-icon-button
(click)="quickAccessBtnClick($event, 'today')"
[matTooltip]="T.F.TASK.D_DEADLINE.QA_TODAY | translate"
>
<mat-icon>wb_sunny</mat-icon>
</button>
<button
mat-icon-button
(click)="quickAccessBtnClick($event, 'tomorrow')"
[matTooltip]="T.F.TASK.D_DEADLINE.QA_TOMORROW | translate"
>
<mat-icon>wb_twilight</mat-icon>
</button>
<button
mat-icon-button
(click)="quickAccessBtnClick($event, 'nextWeek')"
[matTooltip]="T.F.TASK.D_DEADLINE.QA_NEXT_WEEK | translate"
>
<mat-icon>next_week</mat-icon>
</button>
<button
mat-icon-button
(click)="quickAccessBtnClick($event, 'nextMonth')"
[matTooltip]="T.F.TASK.D_DEADLINE.QA_NEXT_MONTH | translate"
>
<mat-icon>bedtime</mat-icon>
</button>
</div>
<mat-dialog-content>
@if (isConfigReady()) {
<mat-calendar
(keydown)="onKeyDownOnCalendar($event)"
[selected]="selectedDate"
(selectedChange)="dateSelected($event)"
></mat-calendar>
<datetime-picker
[selectedDate]="selectedDate"
[selectedTime]="selectedTime"
[selectedReminderCfgId]="selectedReminderCfgId"
[reminderOptions]="reminderOptions"
[minDate]="minDate"
[timeLabel]="T.F.TASK.D_DEADLINE.ADD_TIME | translate"
[reminderLabel]="T.F.TASK.D_DEADLINE.REMIND_AT"
[quickAccessTranslationPrefix]="'F.TASK.D_DEADLINE'"
(dateSelected)="dateSelected($event)"
(timeChanged)="selectedTime = $event"
(reminderChanged)="selectedReminderCfgId = $event"
(quickAccessClick)="onQuickAccessClick($event)"
(enterSubmit)="submit()"
></datetime-picker>
}
@if (isShowEnterMsg) {
<div
class="press-enter-msg"
@fade
>
{{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }}
</div>
}
<div class="form-ctrl-wrapper">
<mat-form-field>
<mat-label>{{ T.F.TASK.D_DEADLINE.ADD_TIME | translate }}</mat-label>
<mat-icon matPrefix>schedule</mat-icon>
<input
type="time"
spTimeStep
(focus)="onTimeFocus()"
[(ngModel)]="selectedTime"
step="60"
matInput
(keydown)="onTimeKeyDown($event)"
/>
@if (selectedTime) {
<mat-icon
class="time-clear-btn"
matSuffix
(click)="onTimeClear($event)"
>close
</mat-icon>
}
</mat-form-field>
@if (selectedTime) {
<mat-form-field [@expandFade]>
<mat-icon matPrefix>alarm</mat-icon>
<mat-label>{{ T.F.TASK.D_DEADLINE.REMIND_AT | translate }}</mat-label>
<mat-select
[(ngModel)]="selectedReminderCfgId"
name="type"
required="true"
>
@for (remindOption of reminderOptions; track remindOption.value) {
<mat-option [value]="remindOption.value">
{{ remindOption.label | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-dialog-actions align="start">
<div class="deadline-actions">
@if (hasExistingDeadline) {
<button
(click)="remove()"
color="warn"
mat-stroked-button
>
<mat-icon>flag_circle</mat-icon>
{{ T.F.TASK.D_DEADLINE.REMOVE_DEADLINE | translate }}
</button>
}
<mat-dialog-actions align="start">
<div class="deadline-actions">
@if (hasExistingDeadline && !data.isSelectDeadlineOnly) {
<button
color="primary"
(click)="remove()"
color="warn"
mat-stroked-button
(click)="submit()"
type="button"
>
<mat-icon>flag</mat-icon>
{{ T.F.TASK.D_DEADLINE.SET_DEADLINE | translate }}
<mat-icon>event_busy</mat-icon>
{{ T.F.TASK.D_DEADLINE.REMOVE_DEADLINE | translate }}
</button>
</div>
}
<button
class="dialog-cancel-btn"
color="primary"
mat-stroked-button
color="default"
(click)="close()"
[disabled]="!selectedDate"
(click)="submit()"
type="button"
>
{{ T.G.CANCEL | translate }}
<mat-icon>event_available</mat-icon>
{{ T.F.TASK.D_DEADLINE.SET_DEADLINE | translate }}
</button>
</mat-dialog-actions>
</div>
</div>
<button
class="dialog-cancel-btn"
mat-stroked-button
color="default"
(click)="close()"
type="button"
>
{{ T.G.CANCEL | translate }}
</button>
</mat-dialog-actions>
</mat-dialog-content>

View file

@ -17,37 +17,6 @@
:host-context([dir='rtl']) {
direction: rtl;
}
mat-form-field {
width: 100%;
}
::ng-deep {
.mat-calendar-header {
padding-top: 0;
}
.mat-calendar-body-cell-content {
background: transparent !important;
}
.mat-calendar-body-cell:focus .mat-calendar-body-cell-content {
outline: 2px solid var(--c-accent);
}
.mat-calendar-body-selected {
background: var(--c-primary) !important;
}
.mat-calendar-controls {
margin-top: var(--s);
margin-bottom: var(--s);
}
}
}
.form-ctrl-wrapper {
margin: var(--s2) var(--s2) 0;
}
mat-dialog-content {
@ -58,47 +27,14 @@ mat-dialog-content {
) !important;
}
.press-enter-msg {
text-align: center;
font-weight: bold;
padding: var(--s-half) 12px;
position: absolute;
left: 50%;
z-index: 11;
box-shadow: var(--whiteframe-shadow-1dp);
border-radius: var(--radius-md);
margin-top: calc(var(--s) * -1.5);
white-space: nowrap;
transform: translateX(-50%);
background: var(--bg-lighter);
}
.quick-access {
display: flex;
justify-content: space-evenly;
@include extraBorder('-bottom');
height: 48px;
> button {
height: 48px;
flex-grow: 1;
border-radius: var(--card-border-radius) !important;
::ng-deep .mat-mdc-button-persistent-ripple {
border-radius: var(--card-border-radius) !important;
}
}
button + button {
@include extraBorder('-left');
}
}
:host ::ng-deep mat-dialog-actions {
padding: 0 0 var(--s2) 0 !important;
}
.dialog-actions-and-warnings {
margin: 0 var(--s2) var(--s2);
}
.deadline-actions {
display: flex;
justify-content: center;
@ -134,7 +70,3 @@ mat-dialog-content {
overflow-wrap: anywhere;
text-align: center;
}
.time-clear-btn {
cursor: pointer;
}

View file

@ -6,7 +6,6 @@ import {
computed,
ElementRef,
inject,
viewChild,
} from '@angular/core';
import {
MAT_DIALOG_DATA,
@ -16,41 +15,26 @@ import {
} from '@angular/material/dialog';
import { Task, TaskReminderOption, TaskReminderOptionId } from '../task.model';
import { T } from 'src/app/t.const';
import { MatCalendar } from '@angular/material/datepicker';
import { Store } from '@ngrx/store';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { DEADLINE_REMINDER_OPTIONS } from './deadline-reminder-options.const';
import { FormsModule } from '@angular/forms';
import { millisecondsDiffToRemindOption } from '../util/remind-option-to-milliseconds';
import { remindOptionToMilliseconds } from '../util/remind-option-to-milliseconds';
import { expandFadeAnimation } from '../../../ui/animations/expand.ani';
import { fadeAnimation } from '../../../ui/animations/fade.ani';
import { getClockStringFromHours } from '../../../util/get-clock-string-from-hours';
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
import { isValidSplitTime } from '../../../util/is-valid-split-time';
import { normalizeClockStr } from '../../../util/normalize-clock-str';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
import { DateService } from '../../../core/date/date.service';
import { DateAdapter, MatOption } from '@angular/material/core';
import { MatTooltip } from '@angular/material/tooltip';
import { MatButton, MatIconButton } from '@angular/material/button';
import { DateAdapter } from '@angular/material/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import {
MatFormField,
MatLabel,
MatPrefix,
MatSuffix,
} from '@angular/material/form-field';
import { MatSelect } from '@angular/material/select';
import { TranslatePipe } from '@ngx-translate/core';
import { MatInput } from '@angular/material/input';
import { TimeStepDirective } from '../../../ui/time-step/time-step.directive';
import { GlobalConfigService } from '../../config/global-config.service';
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
import { getDeadlineAutoPlanFields } from '../util/get-deadline-auto-plan-fields';
const DEFAULT_TIME = '09:00';
import { DateTimePickerComponent } from '../../../ui/datetime-picker/datetime-picker.component';
type QuickDeadline = 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth';
@ -58,27 +42,16 @@ type QuickDeadline = 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth';
selector: 'dialog-deadline',
imports: [
FormsModule,
MatTooltip,
MatIconButton,
MatIcon,
MatFormField,
MatSelect,
MatOption,
TranslatePipe,
MatButton,
MatDialogActions,
MatDialogContent,
MatCalendar,
MatInput,
MatLabel,
MatSuffix,
MatPrefix,
TimeStepDirective,
DateTimePickerComponent,
],
templateUrl: './dialog-deadline.component.html',
styleUrl: './dialog-deadline.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [expandFadeAnimation, fadeAnimation],
})
export class DialogDeadlineComponent implements AfterViewInit {
data = inject<{
@ -107,19 +80,15 @@ export class DialogDeadlineComponent implements AfterViewInit {
);
T: typeof T = T;
readonly calendar = viewChild.required(MatCalendar);
reminderOptions: TaskReminderOption[] = DEADLINE_REMINDER_OPTIONS;
task: Task | undefined = this.data.task;
selectedDate: Date | null = null;
selectedTime: string | null = null;
selectedReminderCfgId: TaskReminderOptionId = TaskReminderOptionId.DoNotRemind;
minDate = new Date();
hasExistingDeadline = false;
isInitValOnTimeFocus = true;
isShowEnterMsg = false;
private _timeCheckVal: string | null = null;
ngAfterViewInit(): void {
if (this.task) {
@ -172,47 +141,7 @@ export class DialogDeadlineComponent implements AfterViewInit {
this.selectedReminderCfgId = this.data.targetDeadlineRemindOption;
}
this.calendar().activeDate = new Date(this.selectedDate || new Date());
this._cd.detectChanges();
setTimeout(() => this._focusInitially());
setTimeout(() => this._focusInitially(), 300);
}
private _focusInitially(): void {
const host = this._elRef.nativeElement as HTMLElement;
const selector = this.selectedDate
? '.mat-calendar-body-selected'
: '.mat-calendar-body-today';
(host.querySelector(selector) as HTMLElement)?.parentElement?.focus();
}
onKeyDownOnCalendar(ev: KeyboardEvent): void {
this._timeCheckVal = null;
if (ev.code === 'Enter' || ev.code === 'Space') {
this.isShowEnterMsg = true;
if (
this.selectedDate &&
new Date(this.selectedDate).getTime() ===
new Date(this.calendar().activeDate).getTime()
) {
this.submit();
}
} else {
this.isShowEnterMsg = false;
}
}
onTimeKeyDown(ev: KeyboardEvent): void {
if (ev.key === 'Enter') {
this.isShowEnterMsg = true;
if (this._timeCheckVal === this.selectedTime) {
this.submit();
}
this._timeCheckVal = this.selectedTime;
} else {
this.isShowEnterMsg = false;
}
}
close(): void {
@ -220,33 +149,7 @@ export class DialogDeadlineComponent implements AfterViewInit {
}
dateSelected(newDate: Date): void {
setTimeout(() => {
this.selectedDate = new Date(newDate);
this.calendar().activeDate = this.selectedDate;
});
}
onTimeClear(ev: MouseEvent): void {
ev.stopPropagation();
this.selectedTime = null;
this.selectedReminderCfgId = TaskReminderOptionId.DoNotRemind;
this.isInitValOnTimeFocus = true;
}
onTimeFocus(): void {
if (!this.selectedTime && this.isInitValOnTimeFocus) {
this.isInitValOnTimeFocus = false;
if (this.selectedDate) {
if (this._dateService.isToday(this.selectedDate!)) {
this.selectedTime = getClockStringFromHours((new Date().getHours() + 1) % 24);
} else {
this.selectedTime = DEFAULT_TIME;
}
} else {
this.selectedTime = getClockStringFromHours((new Date().getHours() + 1) % 24);
this.selectedDate = new Date();
}
}
this.selectedDate = new Date(newDate);
}
remove(): void {
@ -317,8 +220,7 @@ export class DialogDeadlineComponent implements AfterViewInit {
this._matDialogRef.close();
}
quickAccessBtnClick(ev: MouseEvent, option: QuickDeadline): void {
ev.stopPropagation();
onQuickAccessClick(option: QuickDeadline): void {
this.selectedDate = this._getQuickDate(option);
this.submit();
}

View file

@ -0,0 +1,57 @@
import { TaskReminderOptionId } from '../task.model';
import {
millisecondsDiffToRemindOption,
remindOptionToMilliseconds,
} from './remind-option-to-milliseconds';
describe('remindOptionToMilliseconds roundtrip', () => {
const DUE_DATE = new Date('2026-01-01T12:00:00Z').getTime();
const options = [
TaskReminderOptionId.AtStart,
TaskReminderOptionId.m5,
TaskReminderOptionId.m10,
TaskReminderOptionId.m15,
TaskReminderOptionId.m30,
TaskReminderOptionId.h1,
];
options.forEach((optId) => {
it(`should roundtrip correctly for ${optId}`, () => {
const remindAt = remindOptionToMilliseconds(DUE_DATE, optId);
expect(remindAt).toBeDefined();
const resultOptId = millisecondsDiffToRemindOption(DUE_DATE, remindAt);
expect(resultOptId).toBe(optId);
});
});
it('should handle quantization correctly (rounding to nearest bucket)', () => {
// 7 minutes before -> should round to m5 (since it's < 10m but >= 5m)
const m7 = 7 * 60 * 1000;
const remindAt7m = DUE_DATE - m7;
expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt7m)).toBe(
TaskReminderOptionId.m5,
);
// 12 minutes before -> should round to m10
const m12 = 12 * 60 * 1000;
const remindAt12m = DUE_DATE - m12;
expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt12m)).toBe(
TaskReminderOptionId.m10,
);
// 3 minutes before -> should round to m5 (since it's closer to 5 than 0)
const m3 = 3 * 60 * 1000;
const remindAt3m = DUE_DATE - m3;
expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt3m)).toBe(
TaskReminderOptionId.m5,
);
// 1 minute before -> should round to AtStart
const m1 = 1 * 60 * 1000;
const remindAt1m = DUE_DATE - m1;
expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt1m)).toBe(
TaskReminderOptionId.AtStart,
);
});
});

View file

@ -1,6 +1,4 @@
import { TaskReminderOptionId } from '../task.model';
import { devError } from '../../../util/dev-error';
import { TaskLog } from '../../../core/log';
export const remindOptionToMilliseconds = (
due: number,
@ -42,21 +40,20 @@ export const millisecondsDiffToRemindOption = (
return TaskReminderOptionId.DoNotRemind;
}
const diff: number = due - remindAt;
if (diff >= 60 * 60 * 1000) {
const diffInMinutes = diff / (60 * 1000);
if (diffInMinutes >= 45) {
return TaskReminderOptionId.h1;
} else if (diff >= 30 * 60 * 1000) {
} else if (diffInMinutes >= 22.5) {
return TaskReminderOptionId.m30;
} else if (diff >= 15 * 60 * 1000) {
} else if (diffInMinutes >= 12.5) {
return TaskReminderOptionId.m15;
} else if (diff >= 10 * 60 * 1000) {
} else if (diffInMinutes >= 7.5) {
return TaskReminderOptionId.m10;
} else if (diff >= 5 * 60 * 1000) {
} else if (diffInMinutes >= 2.5) {
return TaskReminderOptionId.m5;
} else if (diff <= 0) {
return TaskReminderOptionId.AtStart;
} else {
TaskLog.log(due, remindAt);
devError('Cannot determine remind option. Invalid params');
return TaskReminderOptionId.DoNotRemind;
// Also handles diff <= 0
return TaskReminderOptionId.AtStart;
}
};

View file

@ -31,7 +31,16 @@ const T = {
RESTORE_STRAY_BACKUP: 'CONFIRM.RESTORE_STRAY_BACKUP',
},
DATETIME_SCHEDULE: {
MONTH: 'DATETIME_SCHEDULE.MONTH',
NEXT_24_YEARS: 'DATETIME_SCHEDULE.NEXT_24_YEARS',
NEXT_MONTH: 'DATETIME_SCHEDULE.NEXT_MONTH',
NEXT_YEAR: 'DATETIME_SCHEDULE.NEXT_YEAR',
PRESS_ENTER_AGAIN: 'DATETIME_SCHEDULE.PRESS_ENTER_AGAIN',
PREVIOUS_24_YEARS: 'DATETIME_SCHEDULE.PREVIOUS_24_YEARS',
PREVIOUS_MONTH: 'DATETIME_SCHEDULE.PREVIOUS_MONTH',
PREVIOUS_YEAR: 'DATETIME_SCHEDULE.PREVIOUS_YEAR',
SWITCH_TO_MULTI_YEAR_VIEW: 'DATETIME_SCHEDULE.SWITCH_TO_MULTI_YEAR_VIEW',
SWITCH_TO_YEAR_VIEW: 'DATETIME_SCHEDULE.SWITCH_TO_YEAR_VIEW',
},
DIALOG_LOGS: {
COPY: 'DIALOG_LOGS.COPY',

View file

@ -0,0 +1,102 @@
@if (showQuickAccess()) {
<div class="quick-access">
<button
mat-icon-button
type="button"
(click)="quickAccessBtnClick($event, 'today')"
[matTooltip]="quickAccessTranslationPrefix() + '.QA_TODAY' | translate"
>
<mat-icon>wb_sunny</mat-icon>
</button>
<button
mat-icon-button
type="button"
(click)="quickAccessBtnClick($event, 'tomorrow')"
[matTooltip]="quickAccessTranslationPrefix() + '.QA_TOMORROW' | translate"
>
<mat-icon>wb_twilight</mat-icon>
</button>
<button
mat-icon-button
type="button"
(click)="quickAccessBtnClick($event, 'nextWeek')"
[matTooltip]="quickAccessTranslationPrefix() + '.QA_NEXT_WEEK' | translate"
>
<mat-icon>next_week</mat-icon>
</button>
<button
mat-icon-button
type="button"
(click)="quickAccessBtnClick($event, 'nextMonth')"
[matTooltip]="quickAccessTranslationPrefix() + '.QA_NEXT_MONTH' | translate"
>
<mat-icon>bedtime</mat-icon>
</button>
</div>
}
@if (isConfigReady()) {
<mat-calendar
(keydown)="onKeyDownOnCalendar($event)"
[selected]="calendarSelectedDate"
[minDate]="minDate()"
(selectedChange)="dateSelected.emit($event)"
></mat-calendar>
}
@if (isShowEnterMsg) {
<div
class="press-enter-msg"
@fade
>
{{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }}
</div>
}
<div class="form-ctrl-wrapper">
<mat-form-field class="time-form-field">
<mat-label>{{ timeLabel() }}</mat-label>
<mat-icon matPrefix>schedule</mat-icon>
<input
type="time"
spTimeStep
(focus)="onTimeFocus()"
[ngModel]="selectedTime()"
(ngModelChange)="onTimeChange($event)"
step="60"
matInput
(keydown)="onTimeKeyDown($event)"
/>
@if (selectedTime()) {
<mat-icon
class="time-clear-btn"
matSuffix
(click)="onTimeClear($event)"
>close
</mat-icon>
}
</mat-form-field>
@if (selectedTime()) {
<mat-form-field
[@expandFade]
class="reminder-form-field"
>
<mat-icon matPrefix>alarm</mat-icon>
<mat-label>{{ reminderLabel() | translate }}</mat-label>
<mat-select
[ngModel]="selectedReminderCfgId()"
(ngModelChange)="onReminderChange($event)"
name="type"
required="true"
>
@for (remindOption of reminderOptions(); track remindOption.value) {
<mat-option [value]="remindOption.value">
{{ remindOption.label | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>

View file

@ -0,0 +1,164 @@
@use '../../../styles/_globals.scss' as *;
:host {
display: block;
user-select: none;
width: 100%;
&.sp-hide-cursor {
cursor: none !important;
::ng-deep {
.mat-calendar-body-cell-content {
cursor: none !important;
}
// Suppress hover-only styling (when not focused) when cursor is hidden
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover:not(:focus)
> .mat-calendar-body-cell-content {
outline: none !important;
&:not(.mat-calendar-body-selected) {
background-color: transparent !important;
}
}
}
}
::ng-deep {
mat-calendar {
height: 400px;
}
.mat-calendar-header {
padding-top: 0;
}
.mat-calendar-body-selected {
background: var(--c-primary) !important;
color: var(--c-contrast) !important;
}
.mat-calendar-controls {
margin-top: var(--s);
margin-bottom: var(--s);
}
// Hide extra month name inside the calendar body
// NOTE: visibility: hidden (not display: none) is intentional the cell must
// stay in the grid flow so the first week's day numbers are offset to the
// correct weekday column. display: none collapses it and left-aligns all days.
.mat-calendar-body-label {
visibility: hidden !important;
padding: 0 !important;
}
.mat-calendar-period-button {
cursor: pointer !important;
transition: background-color var(--transition-duration-s) ease;
&:hover {
background-color: var(--calendar-hover-bg) !important;
border-radius: var(--card-border-radius) !important;
}
}
// Style previous/next buttons hover circles
.mat-calendar-previous-button:not(:disabled):hover,
.mat-calendar-next-button:not(:disabled):hover {
background-color: var(--calendar-hover-bg) !important;
border-radius: 50% !important;
}
}
:host-context([dir='rtl']) {
::ng-deep {
.mat-calendar-previous-button,
.mat-calendar-next-button {
transform: rotate(180deg);
}
}
}
}
.quick-access {
display: flex;
justify-content: space-evenly;
@include extraBorder('-bottom');
height: 48px;
> button {
height: 48px;
flex-grow: 1;
border-radius: var(--card-border-radius) !important;
::ng-deep .mat-mdc-button-persistent-ripple {
border-radius: var(--card-border-radius) !important;
}
}
button + button {
@include extraBorder('-left');
}
}
:host ::ng-deep mat-month-view .mat-calendar-body-today {
color: transparent !important;
border: none !important;
border-color: transparent !important;
outline: none !important;
box-shadow: none !important;
&:after {
@include materialIcon('wb_sunny');
@include center;
color: var(--c-accent) !important;
}
&.mat-calendar-body-selected:after {
color: var(--c-contrast) !important;
}
}
:host ::ng-deep {
mat-year-view .mat-calendar-body-today,
mat-multi-year-view .mat-calendar-body-today {
border: none !important;
border-color: transparent !important;
outline: none !important;
box-shadow: none !important;
&:not(.mat-calendar-body-selected) {
color: var(--c-accent) !important;
}
}
}
.press-enter-msg {
text-align: center;
font-weight: bold;
padding: var(--s-half) 12px;
position: absolute;
left: 50%;
z-index: 11;
box-shadow: var(--whiteframe-shadow-1dp);
border-radius: 8px;
margin-top: -12px;
white-space: nowrap;
transform: translateX(-50%);
background: var(--bg-lighter);
}
.form-ctrl-wrapper {
margin: var(--s2) var(--s2) var(--s2);
display: flex;
flex-direction: column;
gap: var(--s);
mat-form-field {
width: 100%;
}
.time-clear-btn {
cursor: pointer;
}
}

View file

@ -0,0 +1,196 @@
import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
import { DateTimePickerComponent } from './datetime-picker.component';
import { DateService } from '../../core/date/date.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { MatNativeDateModule } from '@angular/material/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService, TranslateStore } from '@ngx-translate/core';
import { signal } from '@angular/core';
import { TaskReminderOptionId } from '../../features/tasks/task.model';
describe('DateTimePickerComponent', () => {
let component: DateTimePickerComponent;
let fixture: ComponentFixture<DateTimePickerComponent>;
let dateServiceSpy: jasmine.SpyObj<DateService>;
let globalConfigServiceMock: any;
beforeEach(async () => {
dateServiceSpy = jasmine.createSpyObj('DateService', ['isToday', 'todayStr']);
globalConfigServiceMock = {
localization: signal({}),
cfg: signal({}),
};
await TestBed.configureTestingModule({
imports: [
DateTimePickerComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
MatNativeDateModule,
],
providers: [
{ provide: DateService, useValue: dateServiceSpy },
{ provide: GlobalConfigService, useValue: globalConfigServiceMock },
TranslateService,
TranslateStore,
],
}).compileComponents();
fixture = TestBed.createComponent(DateTimePickerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should render the calendar by default', () => {
const calendarEl = fixture.nativeElement.querySelector('mat-calendar');
expect(calendarEl).toBeTruthy();
});
it('should emit dateSelected when a date is selected on the calendar', () => {
spyOn(component.dateSelected, 'emit');
const testDate = new Date();
component.dateSelected.emit(testDate);
expect(component.dateSelected.emit).toHaveBeenCalledWith(testDate);
});
it('should emit timeChanged when onTimeChange is called', () => {
spyOn(component.timeChanged, 'emit');
component.onTimeChange('10:30');
expect(component.timeChanged.emit).toHaveBeenCalledWith('10:30');
});
it('should emit reminderChanged when onReminderChange is called', () => {
spyOn(component.reminderChanged, 'emit');
component.onReminderChange(TaskReminderOptionId.AtStart);
expect(component.reminderChanged.emit).toHaveBeenCalledWith(
TaskReminderOptionId.AtStart,
);
});
it('should emit quickAccessClick when quickAccessBtnClick is called', () => {
spyOn(component.quickAccessClick, 'emit');
const mockEvent = new MouseEvent('click');
component.quickAccessBtnClick(mockEvent, 'tomorrow');
expect(component.quickAccessClick.emit).toHaveBeenCalledWith('tomorrow');
});
it('should emit timeChanged with null and reminderChanged with DoNotRemind when onTimeClear is called', () => {
spyOn(component.timeChanged, 'emit');
spyOn(component.reminderChanged, 'emit');
const mockEvent = new MouseEvent('click');
component.onTimeClear(mockEvent);
expect(component.timeChanged.emit).toHaveBeenCalledWith(null);
expect(component.reminderChanged.emit).toHaveBeenCalledWith(
TaskReminderOptionId.DoNotRemind,
);
});
it('should autofill time on focus', () => {
spyOn(component.timeChanged, 'emit');
spyOn(component.dateSelected, 'emit');
fixture.componentRef.setInput('selectedDate', new Date(2026, 4, 6));
fixture.componentRef.setInput('selectedTime', null);
dateServiceSpy.isToday.and.returnValue(false);
component.onTimeFocus();
expect(component.timeChanged.emit).toHaveBeenCalledWith('09:00');
});
it('should toggle isKeyboardNavigating based on keyboard navigation and mouse move', () => {
expect(component.isKeyboardNavigating).toBeFalse();
// Trigger keyboard navigation key down on calendar
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown' });
component.onKeyDownOnCalendar(arrowDownEvent);
expect(component.isKeyboardNavigating).toBeTrue();
// Trigger non-navigation key down on calendar - should not reset it
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
component.onKeyDownOnCalendar(enterEvent);
expect(component.isKeyboardNavigating).toBeTrue();
// Trigger mousemove with changed coordinates - should reset it to false
const mouseMoveEvent = new MouseEvent('mousemove', { clientX: 30, clientY: 40 });
component.onHostMouseMove(mouseMoveEvent);
expect(component.isKeyboardNavigating).toBeFalse();
});
it('should only update calendar activeDate when selectedDate changes', () => {
const calendar = component.calendar()!;
const initialDate = new Date(2026, 4, 6);
const sameDate = new Date(2026, 4, 6);
const differentDate = new Date(2026, 4, 7);
// Initial sync
fixture.componentRef.setInput('selectedDate', initialDate);
fixture.detectChanges();
expect(calendar.activeDate).toEqual(initialDate);
// Navigation (simulated by manual update to activeDate)
calendar.activeDate = new Date(2026, 5, 1);
// Sync with same date value - should NOT reset activeDate
fixture.componentRef.setInput('selectedDate', sameDate);
fixture.detectChanges();
expect(calendar.activeDate).toEqual(new Date(2026, 5, 1));
// Sync with different date value - SHOULD reset activeDate
fixture.componentRef.setInput('selectedDate', differentDate);
fixture.detectChanges();
expect(calendar.activeDate).toEqual(differentDate);
});
it('should emit enterSubmit when Enter is pressed on the calendar and date matches', () => {
spyOn(component.enterSubmit, 'emit');
fixture.componentRef.setInput('selectedDate', new Date(2026, 4, 6));
fixture.detectChanges();
const calendar = component.calendar()!;
calendar.activeDate = new Date(2026, 4, 6);
const enterEvent = new KeyboardEvent('keydown', { code: 'Enter' });
component.onKeyDownOnCalendar(enterEvent);
expect(component.enterSubmit.emit).toHaveBeenCalled();
expect(component.isShowEnterMsg).toBeTrue();
});
it('should require two Enter presses to emit enterSubmit on time input', () => {
spyOn(component.enterSubmit, 'emit');
fixture.componentRef.setInput('selectedTime', '10:30');
fixture.detectChanges();
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
// First press
component.onTimeKeyDown(enterEvent);
expect(component.enterSubmit.emit).not.toHaveBeenCalled();
expect(component.isShowEnterMsg).toBeTrue();
// Second press
component.onTimeKeyDown(enterEvent);
expect(component.enterSubmit.emit).toHaveBeenCalled();
});
it('should focus the active calendar cell after view init', fakeAsync(() => {
// Create an element that querySelector will find
const activeCell = document.createElement('div');
activeCell.classList.add('mat-calendar-body-active');
activeCell.tabIndex = 0;
fixture.nativeElement.appendChild(activeCell);
spyOn(activeCell, 'focus');
// Mock querySelector on the native element
spyOn(fixture.nativeElement, 'querySelector').and.returnValue(activeCell);
component.ngAfterViewInit();
tick(50); // Match the 50ms in component
expect(activeCell.focus).toHaveBeenCalled();
}));
});

View file

@ -0,0 +1,290 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
effect,
ElementRef,
HostBinding,
HostListener,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCalendar } from '@angular/material/datepicker';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import {
MatFormField,
MatLabel,
MatPrefix,
MatSuffix,
} from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatSelect } from '@angular/material/select';
import { MatOption } from '@angular/material/core';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslateModule, TranslatePipe } from '@ngx-translate/core';
import { T } from '../../t.const';
import { DateService } from '../../core/date/date.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
import {
TaskReminderOption,
TaskReminderOptionId,
} from '../../features/tasks/task.model';
import { TASK_REMINDER_OPTIONS } from '../../features/planner/dialog-schedule-task/task-reminder-options.const';
import { TimeStepDirective } from '../time-step/time-step.directive';
import { expandFadeAnimation } from '../animations/expand.ani';
import { fadeAnimation } from '../animations/fade.ani';
import { getClockStringFromHours } from '../../util/get-clock-string-from-hours';
const DEFAULT_TIME = '09:00';
@Component({
selector: 'datetime-picker',
standalone: true,
imports: [
FormsModule,
MatCalendar,
MatButtonModule,
MatIcon,
MatFormField,
MatLabel,
MatPrefix,
MatSuffix,
MatInput,
MatSelect,
MatOption,
MatTooltip,
TranslateModule,
TranslatePipe,
TimeStepDirective,
],
templateUrl: './datetime-picker.component.html',
styleUrl: './datetime-picker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [expandFadeAnimation, fadeAnimation],
})
export class DateTimePickerComponent implements AfterViewInit {
private _dateService = inject(DateService);
private _globalConfigService = inject(GlobalConfigService);
private readonly _cdr = inject(ChangeDetectorRef);
private _el = inject(ElementRef);
pickerSelectedDate: Date | null = null;
constructor() {
effect((onCleanup) => {
const cal = this.calendar();
if (cal) {
// Set initial view and date
if (cal.currentView !== 'month') {
this.pickerSelectedDate = cal.activeDate;
}
const sub = cal.stateChanges.subscribe(() => {
if (cal.currentView !== 'month') {
this.pickerSelectedDate = cal.activeDate;
}
this._cdr.markForCheck();
});
onCleanup(() => sub.unsubscribe());
}
});
}
// Inputs
selectedDate = input<Date | null>(null);
selectedTime = input<string | null>(null);
selectedReminderCfgId = input<TaskReminderOptionId>(TaskReminderOptionId.DoNotRemind);
reminderOptions = input<TaskReminderOption[]>(TASK_REMINDER_OPTIONS);
minDate = input<Date | null>(null);
timeLabel = input<string>('Time');
reminderLabel = input<string>(T.F.TASK.D_SCHEDULE_TASK.REMIND_AT);
showQuickAccess = input<boolean>(true);
quickAccessTranslationPrefix = input<string>('F.TASK.D_SCHEDULE_TASK');
// Outputs
dateSelected = output<Date>();
timeChanged = output<string | null>();
reminderChanged = output<TaskReminderOptionId>();
quickAccessClick = output<'today' | 'tomorrow' | 'nextWeek' | 'nextMonth'>();
enterSubmit = output<void>();
// Template variables
T: typeof T = T;
isInitValOnTimeFocus = true;
isShowEnterMsg = false;
@HostBinding('class.sp-hide-cursor') isKeyboardNavigating = false;
readonly calendar = viewChild(MatCalendar);
readonly isConfigReady = computed(
() => this._globalConfigService.localization() !== undefined,
);
get calendarSelectedDate(): Date | null {
const cal = this.calendar();
if (!cal) {
return this.selectedDate();
}
if (cal.currentView === 'month') {
return this.selectedDate();
}
return this.pickerSelectedDate || cal.activeDate;
}
private _lastSyncedDate: number | null = null;
private _syncActiveDateEffect = effect(() => {
const date = this.selectedDate();
const dateMs = date ? new Date(date).getTime() : null;
if (dateMs === this._lastSyncedDate) {
return;
}
this._lastSyncedDate = dateMs;
const cal = this.calendar();
if (cal) {
cal.activeDate = new Date(date || new Date());
}
});
private _timeCheckVal: string | null = null;
onKeyDownOnCalendar(ev: KeyboardEvent): void {
this._timeCheckVal = null;
if (ev.code === 'Enter' || ev.code === 'Space') {
this.isShowEnterMsg = true;
const cal = this.calendar();
const selDate = this.selectedDate();
if (
cal &&
selDate &&
new Date(selDate).getTime() === new Date(cal.activeDate).getTime()
) {
this.enterSubmit.emit();
}
} else {
this.isShowEnterMsg = false;
}
if (
[
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
'PageUp',
'PageDown',
].includes(ev.key)
) {
this.isKeyboardNavigating = true;
this._cdr.markForCheck();
}
}
onTimeFocus(): void {
if (!this.selectedTime() && this.isInitValOnTimeFocus) {
this.isInitValOnTimeFocus = false;
let targetTime: string;
let targetDate: Date | null = null;
const selDate = this.selectedDate();
if (selDate) {
if (this._dateService.isToday(selDate)) {
targetTime = getClockStringFromHours((new Date().getHours() + 1) % 24);
} else {
targetTime = DEFAULT_TIME;
}
} else {
// get current time +1h
targetTime = getClockStringFromHours((new Date().getHours() + 1) % 24);
targetDate = new Date();
}
if (targetDate) {
this.dateSelected.emit(targetDate);
}
this.timeChanged.emit(targetTime);
}
}
onTimeChange(newTime: string | null): void {
this.timeChanged.emit(newTime);
}
onReminderChange(newReminder: TaskReminderOptionId): void {
this.reminderChanged.emit(newReminder);
}
onTimeKeyDown(ev: KeyboardEvent): void {
if (ev.key === 'Enter') {
this.isShowEnterMsg = true;
if (this._timeCheckVal === this.selectedTime()) {
this.enterSubmit.emit();
}
this._timeCheckVal = this.selectedTime();
} else {
this.isShowEnterMsg = false;
}
}
onTimeClear(ev: MouseEvent): void {
ev.stopPropagation();
this.timeChanged.emit(null);
this.reminderChanged.emit(TaskReminderOptionId.DoNotRemind);
this.isInitValOnTimeFocus = true;
this._timeCheckVal = null;
}
quickAccessBtnClick(
ev: MouseEvent,
val: 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth',
): void {
ev.preventDefault();
this.quickAccessClick.emit(val);
}
private _lastMouseCoords: { x: number; y: number } | null = null;
ngAfterViewInit(): void {
// Focus the active calendar cell when the picker opens
setTimeout(() => {
const activeCell = this._el.nativeElement.querySelector(
'.mat-calendar-body-active',
) as HTMLElement;
if (activeCell) {
activeCell.focus();
}
}, 50);
}
@HostListener('mousemove', ['$event'])
onHostMouseMove(ev: MouseEvent): void {
this._resetKeyboardNav(ev);
}
private _resetKeyboardNav(ev: MouseEvent): boolean {
const coords = { x: ev.clientX, y: ev.clientY };
if (
this._lastMouseCoords &&
this._lastMouseCoords.x === coords.x &&
this._lastMouseCoords.y === coords.y
) {
return false;
}
this._lastMouseCoords = coords;
if (this.isKeyboardNavigating) {
this.isKeyboardNavigating = false;
this._cdr.markForCheck();
}
return true;
}
}

View file

@ -30,7 +30,16 @@
"RESTORE_STRAY_BACKUP": "During the last sync, there may have been an error. Do you want to restore the last backup?"
},
"DATETIME_SCHEDULE": {
"PRESS_ENTER_AGAIN": "Press enter again to save"
"MONTH": "Month",
"NEXT_24_YEARS": "Next 24 years",
"NEXT_MONTH": "Next month",
"NEXT_YEAR": "Next year",
"PRESS_ENTER_AGAIN": "Press enter again to save",
"PREVIOUS_24_YEARS": "Previous 24 years",
"PREVIOUS_MONTH": "Previous month",
"PREVIOUS_YEAR": "Previous year",
"SWITCH_TO_MULTI_YEAR_VIEW": "Choose year",
"SWITCH_TO_YEAR_VIEW": "Choose month"
},
"DIALOG_LOGS": {
"COPY": "Copy",

View file

@ -44,6 +44,7 @@ import {
MatDateFormats,
DateAdapter,
} from '@angular/material/core';
import { MatDatepickerIntl } from '@angular/material/datepicker';
import { FormlyConfigModule } from './app/ui/formly-config.module';
import { markedOptionsFactory } from './app/ui/marked-options-factory';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
@ -89,6 +90,7 @@ import { GlobalConfigService } from './app/features/config/global-config.service
import { LocaleDatePipe } from './app/ui/pipes/locale-date.pipe';
import { DateTimeFormatService } from './app/core/date-time-format/date-time-format.service';
import { CustomDateAdapter } from './app/core/date-time-format/custom-date-adapter';
import { TranslateMatDatepickerIntl } from './app/core/date-time-format/translate-mat-datepicker-intl';
import { unlockAudioContext } from './app/util/audio-context';
import { NetworkRetryInterceptorService } from './app/core/http/network-retry-interceptor.service';
@ -202,6 +204,7 @@ bootstrapApplication(AppComponent, {
ShortTimeHtmlPipe,
ShortTimePipe,
{ provide: DateAdapter, useClass: CustomDateAdapter },
{ provide: MatDatepickerIntl, useClass: TranslateMatDatepickerIntl },
{
provide: MAT_DATE_FORMATS,
useFactory: (dateTimeFormatService: DateTimeFormatService): MatDateFormats => {
@ -217,7 +220,7 @@ bootstrapApplication(AppComponent, {
get dateInput(): string {
return dateTimeFormatService.dateFormat().raw;
},
monthYearLabel: { year: 'numeric', month: 'short' },
monthYearLabel: { year: 'numeric', month: 'long' },
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
monthYearA11yLabel: { year: 'numeric', month: 'long' },
timeInput: { hour: 'numeric', minute: 'numeric' },

View file

@ -13,6 +13,7 @@
--mat-tab-header-active-hover-label-text-color: var(--palette-primary-700);
--mdc-tab-indicator-active-indicator-color: var(--palette-primary-700);
--mat-tab-header-inactive-label-text-color: var(--text-color-muted);
--calendar-hover-bg: color-mix(in srgb, var(--c-primary) 35%, transparent);
}
// Dark theme tab overrides for better contrast - use lighter shade
@ -22,6 +23,7 @@ body.isDarkTheme {
--mat-tab-header-active-focus-label-text-color: var(--palette-primary-300);
--mat-tab-header-active-hover-label-text-color: var(--palette-primary-300);
--mdc-tab-indicator-active-indicator-color: var(--palette-primary-300);
--calendar-hover-bg: color-mix(in srgb, var(--c-primary) 20%, transparent);
}
// Apply tab colors explicitly to tab components
@ -483,3 +485,20 @@ body.isVerticalActionBar .cdk-overlay-pane.mat-mdc-tooltip-panel {
display: flex !important;
align-items: center !important;
}
// Shared calendar hover and focus overrides
.mat-calendar:not(.no-hover-effects) {
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover
> .mat-calendar-body-cell-content,
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):focus
.mat-calendar-body-cell-content {
outline: 2px solid color-mix(in srgb, var(--c-accent) 80%, transparent) !important;
}
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover
> .mat-calendar-body-cell-content:not(.mat-calendar-body-selected),
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):focus
> .mat-calendar-body-cell-content:not(.mat-calendar-body-selected) {
background-color: var(--calendar-hover-bg) !important;
}
}