feat(tasks): WIP honor dateTimeLocale in schedule time field (#8565)

Native <input type="time"> renders 12h/24h from the browser/OS locale and
cannot honor the in-app "Datetime format locale" setting. On desktop
(web + Electron) use Angular Material <mat-timepicker>, which formats via
the DateAdapter locale that DateTimeFormatService drives from the setting;
keep the native input on touch where the OS time wheel is the better UX.

- bridge HH:mm string <-> Date for the timepicker (clock-str-and-date util)
- mat-timepicker-toggle + type-to-enter; single-Enter commits & submits
- paint autofilled value while focused (mat-timepicker hides it otherwise)
- update e2e selectors to data-test-id="time-input" (no longer type=time)

WIP: desktop field layout/styling still needs work; depends on #8574
(adapter-locale single-owner) which lives on master, not this branch.
This commit is contained in:
Johannes Millan 2026-06-25 16:44:47 +02:00
parent 5cdac0639e
commit b214b93631
9 changed files with 246 additions and 27 deletions

View file

@ -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 <input type="time"> 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

View file

@ -64,7 +64,7 @@ export class PlannerPage extends BasePage {
async scheduleTaskForTime(taskName: string, time: string): Promise<void> {
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');

View file

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

View file

@ -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 <input type="time"> 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;

View file

@ -57,17 +57,41 @@
<div class="form-ctrl-wrapper">
<mat-form-field class="time-form-field">
<mat-label>{{ timeLabel() }}</mat-label>
<mat-icon matPrefix>schedule</mat-icon>
<input
type="time"
spTimeStep
(focus)="onTimeFocus()"
[ngModel]="selectedTime()"
(ngModelChange)="onTimeChange($event)"
step="60"
matInput
(keydown)="onTimeKeyDown($event)"
/>
@if (useMatTimepicker) {
<!-- Desktop: type the time directly (panel does NOT auto-open), or click
the clock toggle to pick. Honors the app's 12h/24h locale setting. -->
<mat-timepicker-toggle
matPrefix
[for]="timepicker"
/>
<input
#timeInputEl
type="text"
data-test-id="time-input"
[matTimepicker]="timepicker"
[matTimepickerOpenOnClick]="false"
[ngModel]="timeAsDate()"
[ngModelOptions]="{ updateOn: 'blur' }"
(ngModelChange)="onTimeDateChange($event)"
(focus)="onTimeFocus()"
(keydown)="onTimeKeyDown($event)"
matInput
/>
<mat-timepicker #timepicker />
} @else {
<mat-icon matPrefix>schedule</mat-icon>
<input
type="time"
data-test-id="time-input"
spTimeStep
(focus)="onTimeFocus()"
[ngModel]="selectedTime()"
(ngModelChange)="onTimeChange($event)"
step="60"
matInput
(keydown)="onTimeKeyDown($event)"
/>
}
@if (selectedTime()) {
<mat-icon
class="time-clear-btn"

View file

@ -89,6 +89,26 @@ describe('DateTimePickerComponent', () => {
);
});
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();

View file

@ -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 (`<input type="time">`) 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 `<mat-timepicker>`, 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<ElementRef<HTMLInputElement>>('timeInputEl');
readonly isConfigReady = computed(
() => this._globalConfigService.localization() !== undefined,
);
// `<mat-timepicker>` 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 {

View file

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

View file

@ -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 `<mat-timepicker>` 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);
};