{
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);
+};