mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
5cdac0639e
commit
b214b93631
9 changed files with 246 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
54
src/app/util/clock-str-and-date.spec.ts
Normal file
54
src/app/util/clock-str-and-date.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
47
src/app/util/clock-str-and-date.ts
Normal file
47
src/app/util/clock-str-and-date.ts
Normal 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);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue