diff --git a/e2e/constants/selectors.ts b/e2e/constants/selectors.ts index 4d91492ab3..e3308904ba 100644 --- a/e2e/constants/selectors.ts +++ b/e2e/constants/selectors.ts @@ -121,8 +121,11 @@ export const cssSelectors = { // DATE/TIME SELECTORS // ============================================================================ EDIT_DATE_INFO: '.edit-date-info', - TIME_INPUT: 'input[type="time"]', - MAT_TIME_INPUT: 'mat-form-field input[type="time"]', + // The datetime-picker uses a native on touch and a + // mat-timepicker text input on desktop; both carry data-test-id="time-input". + // Keep the native fallback for other plain time inputs (e.g. settings). + TIME_INPUT: '[data-test-id="time-input"], input[type="time"]', + MAT_TIME_INPUT: '[data-test-id="time-input"]', // ============================================================================ // TASK DETAIL PANEL SELECTORS diff --git a/e2e/pages/planner.page.ts b/e2e/pages/planner.page.ts index bded94f8e0..2caa80dc46 100644 --- a/e2e/pages/planner.page.ts +++ b/e2e/pages/planner.page.ts @@ -64,7 +64,7 @@ export class PlannerPage extends BasePage { async scheduleTaskForTime(taskName: string, time: string): Promise { const task = this.page.locator(`task:has-text("${taskName}")`); - const timeInput = task.locator('input[type="time"]'); + const timeInput = task.locator('[data-test-id="time-input"], input[type="time"]'); await timeInput.fill(time); await this.page.keyboard.press('Enter'); diff --git a/e2e/tests/reminders/reminders-default-task-remind-option.spec.ts b/e2e/tests/reminders/reminders-default-task-remind-option.spec.ts index c4dc49100b..85f868f155 100644 --- a/e2e/tests/reminders/reminders-default-task-remind-option.spec.ts +++ b/e2e/tests/reminders/reminders-default-task-remind-option.spec.ts @@ -95,7 +95,7 @@ test.describe('Default task reminder option', () => { await scheduleDialog.waitFor({ state: 'visible', timeout: 10000 }); // Click on the time input in the schedule dialog to reveal the reminder input - const timeInput = scheduleDialog.locator('input[type="time"]'); + const timeInput = scheduleDialog.locator('[data-test-id="time-input"]'); await timeInput.waitFor({ state: 'visible', timeout: 10000 }); await timeInput.click(); diff --git a/e2e/utils/time-input-helper.ts b/e2e/utils/time-input-helper.ts index f21fce178e..f7982482ca 100644 --- a/e2e/utils/time-input-helper.ts +++ b/e2e/utils/time-input-helper.ts @@ -15,10 +15,12 @@ export const fillTimeInput = async ( const d = new Date(scheduleTime); const timeValue = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; - // Try multiple selectors for the time input + // Try multiple selectors for the time input. The datetime-picker renders a + // native on touch and a mat-timepicker text input on + // desktop; both carry data-test-id="time-input". const timeInput = page - .locator('mat-dialog-container input[type="time"]') - .or(page.locator('mat-form-field input[type="time"]')) + .locator('mat-dialog-container [data-test-id="time-input"]') + .or(page.locator('[data-test-id="time-input"]')) .or(page.locator('input[type="time"]')) .first(); await timeInput.waitFor({ state: 'visible', timeout: 10000 }); @@ -36,7 +38,7 @@ export const fillTimeInput = async ( await page.evaluate( ({ value }: { value: string }) => { const timeInputEl = document.querySelector( - 'mat-form-field input[type="time"]', + '[data-test-id="time-input"]', ) as HTMLInputElement; if (timeInputEl) { timeInputEl.value = value; diff --git a/src/app/ui/datetime-picker/datetime-picker.component.html b/src/app/ui/datetime-picker/datetime-picker.component.html index dbb01b302b..c11817b613 100644 --- a/src/app/ui/datetime-picker/datetime-picker.component.html +++ b/src/app/ui/datetime-picker/datetime-picker.component.html @@ -57,17 +57,41 @@
{{ timeLabel() }} - schedule - + @if (useMatTimepicker) { + + + + + } @else { + schedule + + } @if (selectedTime()) { { ); }); + it('should emit canonical HH:mm when the mat-timepicker Date changes (onTimeDateChange)', () => { + spyOn(component.timeChanged, 'emit'); + component.onTimeDateChange(new Date(2026, 4, 6, 14, 30)); + expect(component.timeChanged.emit).toHaveBeenCalledWith('14:30'); + + component.onTimeDateChange(null); + expect(component.timeChanged.emit).toHaveBeenCalledWith(null); + }); + + it('should bridge selectedTime string to a Date for the mat-timepicker (timeAsDate)', () => { + fixture.componentRef.setInput('selectedDate', new Date(2026, 4, 6)); + fixture.componentRef.setInput('selectedTime', '14:30'); + const d = component.timeAsDate()!; + expect(d.getHours()).toBe(14); + expect(d.getMinutes()).toBe(30); + + fixture.componentRef.setInput('selectedTime', null); + expect(component.timeAsDate()).toBeNull(); + }); + it('should autofill time on focus', () => { spyOn(component.timeChanged, 'emit'); spyOn(component.dateSelected, 'emit'); @@ -233,7 +253,19 @@ describe('DateTimePickerComponent', () => { expect(component.isShowEnterMsg).toBeTrue(); }); - it('should require two Enter presses to emit enterSubmit on time input', () => { + it('should commit and submit on a single Enter on the desktop (mat-timepicker) path', () => { + // Default in the (non-touch) test env: useMatTimepicker === true. + spyOn(component.enterSubmit, 'emit'); + fixture.componentRef.setInput('selectedTime', '10:30'); + fixture.detectChanges(); + + component.onTimeKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(component.enterSubmit.emit).toHaveBeenCalled(); + }); + + it('should require two Enter presses on the native time input path', () => { + // Force the native (touch) branch so the double-Enter confirm is exercised. + Object.defineProperty(component, 'useMatTimepicker', { value: false }); spyOn(component.enterSubmit, 'emit'); fixture.componentRef.setInput('selectedTime', '10:30'); fixture.detectChanges(); diff --git a/src/app/ui/datetime-picker/datetime-picker.component.ts b/src/app/ui/datetime-picker/datetime-picker.component.ts index 93a3e8df2f..ce5ff58040 100644 --- a/src/app/ui/datetime-picker/datetime-picker.component.ts +++ b/src/app/ui/datetime-picker/datetime-picker.component.ts @@ -24,6 +24,11 @@ import { MatSuffix, } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; +import { + MatTimepicker, + MatTimepickerInput, + MatTimepickerToggle, +} from '@angular/material/timepicker'; import { MatSelect } from '@angular/material/select'; import { MatOption } from '@angular/material/core'; import { MatTooltip } from '@angular/material/tooltip'; @@ -40,6 +45,9 @@ 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'; +import { IS_TOUCH_ONLY } from '../../util/is-touch-only'; +import { clockStrToDate, dateToClockStr } from '../../util/clock-str-and-date'; +import { DateTimeFormatService } from '../../core/date-time-format/date-time-format.service'; const DEFAULT_TIME = '09:00'; @@ -56,6 +64,9 @@ const DEFAULT_TIME = '09:00'; MatPrefix, MatSuffix, MatInput, + MatTimepicker, + MatTimepickerInput, + MatTimepickerToggle, MatSelect, MatOption, MatTooltip, @@ -70,6 +81,7 @@ const DEFAULT_TIME = '09:00'; }) export class DateTimePickerComponent implements AfterViewInit { private _dateService = inject(DateService); + private _dateTimeFormat = inject(DateTimeFormatService); private _globalConfigService = inject(GlobalConfigService); private readonly _cdr = inject(ChangeDetectorRef); private _el = inject(ElementRef); @@ -94,17 +106,34 @@ export class DateTimePickerComponent implements AfterViewInit { // Template variables T: typeof T = T; + // On touch-only devices the OS-native time wheel (``) is + // the better experience and already follows the device locale. On desktop the + // native control ignores the in-app `dateTimeLocale` (its 12h/24h is fixed by + // the browser/OS locale — see #8565), so use ``, which formats + // via the Material DateAdapter that DateTimeFormatService drives from the + // user's setting. + readonly useMatTimepicker = !IS_TOUCH_ONLY; isInitValOnTimeFocus = true; isShowEnterMsg = false; @HostBinding('class.sp-hide-cursor') isKeyboardNavigating = false; @HostBinding('class.sp-initial-focus') isInitialFocus = true; readonly calendar = viewChild(MatCalendar); + // Only present on the desktop (mat-timepicker) path; used to flush its + // blur-committed value before an Enter submit. + readonly timeInputEl = viewChild>('timeInputEl'); readonly isConfigReady = computed( () => this._globalConfigService.localization() !== undefined, ); + // `` binds to a Date; bridge it to/from the canonical `HH:mm` + // string contract so the component's inputs/outputs stay unchanged. The + // timepicker only reads hours/minutes, so the date portion is irrelevant — + // we intentionally do NOT depend on selectedDate() to avoid recomputing (and + // re-writing the bound Date) on every calendar-day click. + readonly timeAsDate = computed(() => clockStrToDate(this.selectedTime())); + private _lastView: MatCalendarView | null = null; private _viewChangeEffect = effect((onCleanup) => { const cal = this.calendar(); @@ -208,6 +237,18 @@ export class DateTimePickerComponent implements AfterViewInit { this.dateSelected.emit(targetDate); } this.timeChanged.emit(targetTime); + + // mat-timepicker only writes its formatted value to the input when the + // input is NOT focused, so an autofill-on-focus would leave the field + // looking empty. Paint the value ourselves (same locale format the + // adapter uses) so the focused field shows the autofilled time. + if (this.useMatTimepicker) { + const el = this.timeInputEl()?.nativeElement; + const date = clockStrToDate(targetTime); + if (el && date) { + el.value = this._dateTimeFormat.formatTime(date.getTime()); + } + } } } @@ -215,20 +256,36 @@ export class DateTimePickerComponent implements AfterViewInit { this.timeChanged.emit(newTime); } + onTimeDateChange(date: Date | null): void { + this.timeChanged.emit(dateToClockStr(date)); + } + 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 { + if (ev.key !== 'Enter') { this.isShowEnterMsg = false; + return; } + + if (this.useMatTimepicker) { + // mat-timepicker commits its value on blur (updateOn: 'blur'). Flush the + // typed value first — blur() propagates it synchronously to the parent via + // timeChanged — so the submit acts on what the user just typed, not the + // stale previous value. A single Enter commits and submits. + this.timeInputEl()?.nativeElement.blur(); + this.enterSubmit.emit(); + return; + } + + // Native path: confirm-on-double-Enter (value updates live as you type). + this.isShowEnterMsg = true; + if (this._timeCheckVal === this.selectedTime()) { + this.enterSubmit.emit(); + } + this._timeCheckVal = this.selectedTime(); } onTimeClear(ev: MouseEvent): void { diff --git a/src/app/util/clock-str-and-date.spec.ts b/src/app/util/clock-str-and-date.spec.ts new file mode 100644 index 0000000000..f553fea38b --- /dev/null +++ b/src/app/util/clock-str-and-date.spec.ts @@ -0,0 +1,54 @@ +import { clockStrToDate, dateToClockStr } from './clock-str-and-date'; + +describe('clockStrToDate', () => { + it('converts HH:mm to a Date with those hours/minutes', () => { + const base = new Date(2020, 0, 15, 5, 5, 5, 5); + const d = clockStrToDate('14:30', base)!; + expect(d.getHours()).toBe(14); + expect(d.getMinutes()).toBe(30); + expect(d.getSeconds()).toBe(0); + expect(d.getMilliseconds()).toBe(0); + }); + + it('keeps the date portion from baseDate', () => { + const d = clockStrToDate('08:15', new Date(2021, 5, 9))!; + expect(d.getFullYear()).toBe(2021); + expect(d.getMonth()).toBe(5); + expect(d.getDate()).toBe(9); + }); + + it('normalizes legacy unpadded values', () => { + const d = clockStrToDate('9:00', new Date(2020, 0, 1))!; + expect(d.getHours()).toBe(9); + expect(d.getMinutes()).toBe(0); + }); + + it('returns null for empty or invalid input', () => { + expect(clockStrToDate(null)).toBeNull(); + expect(clockStrToDate(undefined)).toBeNull(); + expect(clockStrToDate('')).toBeNull(); + expect(clockStrToDate('25:00', new Date())).toBeNull(); + expect(clockStrToDate('13:60', new Date())).toBeNull(); + expect(clockStrToDate('abc', new Date())).toBeNull(); + }); +}); + +describe('dateToClockStr', () => { + it('formats a Date to canonical 24h HH:mm', () => { + expect(dateToClockStr(new Date(2020, 0, 1, 14, 30))).toBe('14:30'); + expect(dateToClockStr(new Date(2020, 0, 1, 9, 5))).toBe('09:05'); + expect(dateToClockStr(new Date(2020, 0, 1, 0, 0))).toBe('00:00'); + expect(dateToClockStr(new Date(2020, 0, 1, 23, 59))).toBe('23:59'); + }); + + it('returns null for null or invalid Date', () => { + expect(dateToClockStr(null)).toBeNull(); + expect(dateToClockStr(undefined)).toBeNull(); + expect(dateToClockStr(new Date('not-a-date'))).toBeNull(); + }); + + it('round-trips with clockStrToDate', () => { + expect(dateToClockStr(clockStrToDate('23:45', new Date()))).toBe('23:45'); + expect(dateToClockStr(clockStrToDate('00:00', new Date()))).toBe('00:00'); + }); +}); diff --git a/src/app/util/clock-str-and-date.ts b/src/app/util/clock-str-and-date.ts new file mode 100644 index 0000000000..2227e8f6b5 --- /dev/null +++ b/src/app/util/clock-str-and-date.ts @@ -0,0 +1,47 @@ +import { toPaddedClockStr } from './to-padded-clock-str'; +import { formatTimeHHmm } from './format-time-hhmm'; + +/** + * Convert a canonical `HH:mm` clock string into a `Date`, which is the value + * type Angular Material's `` binds to (it has no string mode). + * + * Only the hours/minutes carry meaning for a time field, but the timepicker + * needs a full `Date`, so the date portion is taken from `baseDate` (or today). + * Input is normalized via {@link toPaddedClockStr}, so legacy unpadded values + * (`9:00`) and stray seconds (`13:30:00`) are handled; invalid/empty input + * yields `null` so the field renders empty. + * + * @example + * clockStrToDate('14:30', new Date(2020, 0, 15)); // Date: 2020-01-15 14:30:00 + * clockStrToDate('25:00'); // null + */ +export const clockStrToDate = ( + clockStr: string | null | undefined, + baseDate?: Date | null, +): Date | null => { + const padded = toPaddedClockStr(clockStr); + if (!padded) { + return null; + } + const [h, m] = padded.split(':').map(Number); + const date = baseDate ? new Date(baseDate) : new Date(); + date.setHours(h, m, 0, 0); + return date; +}; + +/** + * Convert a `Date` from the Material timepicker back into the app's canonical + * `HH:mm` string contract (always 24h, regardless of how the picker displayed + * it). Returns `null` for an empty or invalid `Date`. + * + * @example + * dateToClockStr(new Date(2020, 0, 1, 14, 30)); // '14:30' + * dateToClockStr(null); // null + */ +export const dateToClockStr = (date: Date | null | undefined): string | null => { + if (!date || isNaN(date.getTime())) { + return null; + } + // Reuse the canonical Date -> 'HH:mm' formatter; we only add the null/NaN guard. + return formatTimeHHmm(date); +};