diff --git a/e2e/pages/dialog.page.ts b/e2e/pages/dialog.page.ts index 247ca5a914..169f9dc859 100644 --- a/e2e/pages/dialog.page.ts +++ b/e2e/pages/dialog.page.ts @@ -173,7 +173,7 @@ export class DialogPage extends BasePage { * Open calendar picker */ async openCalendarPicker(): Promise { - 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); diff --git a/e2e/pages/task.page.ts b/e2e/pages/task.page.ts index a868ea74b4..aabe7b690c 100644 --- a/e2e/pages/task.page.ts +++ b/e2e/pages/task.page.ts @@ -204,7 +204,7 @@ export class TaskPage extends BasePage { async waitForTaskCount(expectedCount: number, timeout: number = 10000): Promise { await this.page.waitForFunction( (args) => { - const currentCount = document.querySelectorAll('task').length; + const currentCount = document.querySelectorAll('task, planner-task').length; return currentCount === args.expectedCount; }, { expectedCount }, diff --git a/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts b/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts index b8bca45480..062bd5df40 100644 --- a/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts +++ b/e2e/tests/recurring/invalid-clock-string-bug-7067.spec.ts @@ -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 }); diff --git a/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts b/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts index 3b9d0a966e..a0149f00b5 100644 --- a/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts +++ b/e2e/tests/recurring/recurring-future-start-date-bug-6856.spec.ts @@ -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 }); diff --git a/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts b/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts index 4641acf029..8de0345410 100644 --- a/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts +++ b/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts @@ -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'); diff --git a/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts b/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts index 08dc49e90b..bb3363d239 100644 --- a/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts +++ b/e2e/tests/recurring/repeat-timed-cold-reopen-day-change.spec.ts @@ -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 }); diff --git a/e2e/tests/task-detail/task-detail.spec.ts b/e2e/tests/task-detail/task-detail.spec.ts index c6f9f7acf4..2b76a825e6 100644 --- a/e2e/tests/task-detail/task-detail.spec.ts +++ b/e2e/tests/task-detail/task-detail.spec.ts @@ -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(); diff --git a/e2e/utils/recurring-task-helpers.ts b/e2e/utils/recurring-task-helpers.ts index 392cd68e79..bbb97b04c9 100644 --- a/e2e/utils/recurring-task-helpers.ts +++ b/e2e/utils/recurring-task-helpers.ts @@ -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 => { - 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). */ diff --git a/src/app/core/date-time-format/translate-mat-datepicker-intl.ts b/src/app/core/date-time-format/translate-mat-datepicker-intl.ts new file mode 100644 index 0000000000..f418512bfe --- /dev/null +++ b/src/app/core/date-time-format/translate-mat-datepicker-intl.ts @@ -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(); + } +} diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task-select-due-only.spec.ts b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task-select-due-only.spec.ts index 5361efd8d4..34328faeaa 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task-select-due-only.spec.ts +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task-select-due-only.spec.ts @@ -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'); + })); }); }); diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html index 3421a320f1..b460f622b6 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.html @@ -1,95 +1,22 @@ -
- - - - - -
- @if (isConfigReady()) { - + [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()" + > } - @if (isShowEnterMsg) { -
- {{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }} -
- } - -
- - Time - schedule - - @if (selectedTime) { - close - - } - - - @if (selectedTime) { - - alarm - {{ T.F.TASK.D_SCHEDULE_TASK.REMIND_AT | translate }} - - @for (remindOption of remindAvailableOptions; track remindOption.value) { - - {{ remindOption.label | translate }} - - } - - - } - +
@if ( selectedTime && (scheduleWarnings().hasOverlap || scheduleWarnings().isOutsideWorkHours) @@ -112,11 +39,16 @@
- @if (data.task && (data.task.dueWithTime || plannedDayForTask)) { + @if ( + !data.isSelectDueOnly && + data.task && + (data.task.dueWithTime || plannedDayForTask) + ) { diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss index d32e2d5b4a..73b85909f7 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.scss @@ -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; diff --git a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts index 171a7b176c..fb5cfe8509 100644 --- a/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts +++ b/src/app/features/planner/dialog-schedule-task/dialog-schedule-task.component.ts @@ -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); 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 { 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(); + } } } diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html index 49ea85ebc1..0e367f9928 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html @@ -25,6 +25,26 @@ }
+
+ +
+ { let mockDialogRef: jasmine.SpyObj>; + let mockMatDialog: jasmine.SpyObj; let mockTaskRepeatCfgService: jasmine.SpyObj; let mockTagService: jasmine.SpyObj; let mockGlobalConfigService: jasmine.SpyObj; let mockDateTimeFormatService: jasmine.SpyObj; + let mockDateService: jasmine.SpyObj; const mockRepeatCfg: TaskRepeatCfg = { ...DEFAULT_TASK_REPEAT_CFG, @@ -61,12 +69,22 @@ describe('DialogEditTaskRepeatCfgComponent', () => { getTaskRepeatCfgById$ReturnValue?: Observable | Subject, ): Promise> => { 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, - ): unknown => { - const fields = fixture.componentInstance.essentialFormFields(); - const startDateField = fields.find((f) => f.key === 'startDate'); - return (startDateField?.templateOptions as Record | 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, + }), + }), + ); }); }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts index b5063e3998..1e185e0a57 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts @@ -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 | 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).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) => { 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); } diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts index adc53f2583..ca068b7bb7 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts @@ -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)', () => { diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts index 3fccc6d28d..bce6326523 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts @@ -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', diff --git a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.html b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.html index e7dc5d0a65..586287bebe 100644 --- a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.html +++ b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.html @@ -1,123 +1,54 @@ -
- - - - - -
- @if (isConfigReady()) { - + } - @if (isShowEnterMsg) { -
- {{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }} -
- } - -
- - {{ T.F.TASK.D_DEADLINE.ADD_TIME | translate }} - schedule - - @if (selectedTime) { - close - - } - - - @if (selectedTime) { - - alarm - {{ T.F.TASK.D_DEADLINE.REMIND_AT | translate }} - - @for (remindOption of reminderOptions; track remindOption.value) { - - {{ remindOption.label | translate }} - - } - - - } - - -
- @if (hasExistingDeadline) { - - } + +
+ @if (hasExistingDeadline && !data.isSelectDeadlineOnly) { -
+ } -
-
+
+ +
diff --git a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.scss b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.scss index 17f457bacf..11acc83f0a 100644 --- a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.scss +++ b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.scss @@ -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; -} diff --git a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts index 763270af6c..3593a06bac 100644 --- a/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts +++ b/src/app/features/tasks/dialog-deadline/dialog-deadline.component.ts @@ -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(); } diff --git a/src/app/features/tasks/util/remind-option-to-milliseconds.spec.ts b/src/app/features/tasks/util/remind-option-to-milliseconds.spec.ts new file mode 100644 index 0000000000..7339e67136 --- /dev/null +++ b/src/app/features/tasks/util/remind-option-to-milliseconds.spec.ts @@ -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, + ); + }); +}); diff --git a/src/app/features/tasks/util/remind-option-to-milliseconds.ts b/src/app/features/tasks/util/remind-option-to-milliseconds.ts index d64da14e30..5fca24ed4b 100644 --- a/src/app/features/tasks/util/remind-option-to-milliseconds.ts +++ b/src/app/features/tasks/util/remind-option-to-milliseconds.ts @@ -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; } }; diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 36daedd63c..fc846fea53 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', diff --git a/src/app/ui/datetime-picker/datetime-picker.component.html b/src/app/ui/datetime-picker/datetime-picker.component.html new file mode 100644 index 0000000000..80cc8491ff --- /dev/null +++ b/src/app/ui/datetime-picker/datetime-picker.component.html @@ -0,0 +1,102 @@ +@if (showQuickAccess()) { +
+ + + + + +
+} + +@if (isConfigReady()) { + +} + +@if (isShowEnterMsg) { +
+ {{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }} +
+} + +
+ + {{ timeLabel() }} + schedule + + @if (selectedTime()) { + close + + } + + + @if (selectedTime()) { + + alarm + {{ reminderLabel() | translate }} + + @for (remindOption of reminderOptions(); track remindOption.value) { + + {{ remindOption.label | translate }} + + } + + + } +
diff --git a/src/app/ui/datetime-picker/datetime-picker.component.scss b/src/app/ui/datetime-picker/datetime-picker.component.scss new file mode 100644 index 0000000000..b8e152ee0f --- /dev/null +++ b/src/app/ui/datetime-picker/datetime-picker.component.scss @@ -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; + } +} diff --git a/src/app/ui/datetime-picker/datetime-picker.component.spec.ts b/src/app/ui/datetime-picker/datetime-picker.component.spec.ts new file mode 100644 index 0000000000..5a80a46680 --- /dev/null +++ b/src/app/ui/datetime-picker/datetime-picker.component.spec.ts @@ -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; + let dateServiceSpy: jasmine.SpyObj; + 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(); + })); +}); diff --git a/src/app/ui/datetime-picker/datetime-picker.component.ts b/src/app/ui/datetime-picker/datetime-picker.component.ts new file mode 100644 index 0000000000..87cfe8cb4e --- /dev/null +++ b/src/app/ui/datetime-picker/datetime-picker.component.ts @@ -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(null); + selectedTime = input(null); + selectedReminderCfgId = input(TaskReminderOptionId.DoNotRemind); + reminderOptions = input(TASK_REMINDER_OPTIONS); + minDate = input(null); + timeLabel = input('Time'); + reminderLabel = input(T.F.TASK.D_SCHEDULE_TASK.REMIND_AT); + showQuickAccess = input(true); + quickAccessTranslationPrefix = input('F.TASK.D_SCHEDULE_TASK'); + + // Outputs + dateSelected = output(); + timeChanged = output(); + reminderChanged = output(); + quickAccessClick = output<'today' | 'tomorrow' | 'nextWeek' | 'nextMonth'>(); + enterSubmit = output(); + + // 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; + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 03d9329d23..6983bfc663 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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", diff --git a/src/main.ts b/src/main.ts index ddb80ce97e..ff989cd17e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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' }, diff --git a/src/styles/components/_overwrite-material.scss b/src/styles/components/_overwrite-material.scss index 36bd55e67d..6de6fd6e86 100644 --- a/src/styles/components/_overwrite-material.scss +++ b/src/styles/components/_overwrite-material.scss @@ -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; + } +}