fix: time selector always resets to 00:00

This commit is contained in:
Karl Power 2026-04-17 11:36:22 +02:00
parent 5885d47964
commit 4bf759f76e
4 changed files with 157 additions and 7 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: time selector always resets to 00:00

View file

@ -43,7 +43,7 @@ const modeAtom = atomWithStorage<TimePickerMode>(
TimePickerMode.Range,
);
const DATE_INPUT_PLACEHOLDER = 'YYY-MM-DD HH:mm:ss';
const DATE_INPUT_PLACEHOLDER = 'YYYY-MM-DD HH:mm:ss';
const DATE_INPUT_FORMAT = 'YYYY-MM-DD HH:mm:ss';
/** Ensure a value is a Date object (Mantine v9 DateInput returns strings). */
@ -55,6 +55,11 @@ const toDate = (v: Date | string | null): Date | null =>
* Mantine v9 DateInput expects/emits string values, but the TimePickerForm
* stores Date objects (used by date-fns). This wrapper converts in both
* directions: value (Date string) and onChange (string Date).
*
* `withTime` is required: by default DateInput strips the time part and
* normalizes values to midnight, even when `valueFormat` includes time
* tokens. Setting `withTime` preserves HH:mm:ss so manually-typed times
* survive blur/commit.
*/
type DateInputCmpProps = Omit<DateInputProps, 'value' | 'onChange'> & {
value?: Date | null;
@ -69,6 +74,7 @@ const DateInputCmp = ({
<DateInput
size="xs"
highlightToday
withTime
placeholder={DATE_INPUT_PLACEHOLDER}
valueFormat={DATE_INPUT_FORMAT}
variant="filled"

View file

@ -177,14 +177,64 @@ export class TimePickerComponent {
}
/**
* Set a custom time range and apply
* Locator for the Start time / "Time" DateInput inside the picker popover.
* Both Start and End inputs share the same placeholder, so we disambiguate
* by ordinal. In Range mode this is the first DateInput; in Around mode
* the single DateInput is also the first (and only).
*/
get startDateInput() {
return this.pickerPopover.getByPlaceholder('YYYY-MM-DD HH:mm:ss').nth(0);
}
/**
* Locator for the End time DateInput. Only present in Range mode.
*/
get endDateInput() {
return this.pickerPopover.getByPlaceholder('YYYY-MM-DD HH:mm:ss').nth(1);
}
/**
* Switch the picker to "Time range" mode (two date inputs).
* Safe to call when already in Range mode (SegmentedControl is idempotent).
*
* Mantine's SegmentedControl visually hides the underlying radio inputs,
* so `getByRole('radio')` resolves to an element that Playwright considers
* not visible. Click the associated label instead same pattern as
* ChartEditorComponent.switchToSqlMode().
*/
async selectRangeMode() {
const rangeLabel = this.pickerPopover.locator(
'.mantine-SegmentedControl-label:has-text("Time range")',
);
await rangeLabel.waitFor({ state: 'visible', timeout: 5000 });
await rangeLabel.click();
}
/**
* Fill the Start time (or, in Around mode, the "Time") input with a
* datetime string. Uses fill() + Enter to trigger the component's blur
* handler, which commits the parsed value back to form state.
*/
async fillStartDate(value: string) {
await this.startDateInput.fill(value);
await this.startDateInput.press('Enter');
}
/**
* Fill the End time input with a datetime string. Only valid in Range mode.
*/
async fillEndDate(value: string) {
await this.endDateInput.fill(value);
await this.endDateInput.press('Enter');
}
/**
* Set a custom absolute time range via the Start/End inputs and apply.
* Assumes the popover is already open and relative mode is disabled.
*/
async setCustomTimeRange(from: string, to: string) {
await this.open();
// This would need to be implemented based on actual UI
// Just a placeholder for the pattern
await this.page.getByTestId('custom-from').fill(from);
await this.page.getByTestId('custom-to').fill(to);
await this.fillStartDate(from);
await this.fillEndDate(to);
await this.apply();
}
}

View file

@ -0,0 +1,89 @@
import { SearchPage } from '../../page-objects/SearchPage';
import { expect, test } from '../../utils/base-test';
/**
* Regression coverage for the "manually typed time resets to 00:00" bug.
*
* Before the fix, Mantine's DateInput (used in `DateInputCmp`) was invoked
* without the `withTime` prop. Even though `valueFormat` included HH:mm:ss,
* DateInput stripped the time portion on blur and normalized values to
* midnight, so any manually typed Start/End time was lost.
*
* These tests type non-midnight times into the Start and End inputs and
* assert the values survive both the immediate commit (Enter blur) and a
* full close/reopen of the picker popover.
*/
test.describe('Custom Time Range', { tag: '@custom-time-range' }, () => {
let searchPage: SearchPage;
test.beforeEach(async ({ page }) => {
searchPage = new SearchPage(page);
await searchPage.goto();
await expect(searchPage.form).toBeVisible();
await searchPage.timePicker.open();
// Search page defaults to live/relative mode; switch to absolute so the
// Range inputs become editable.
await searchPage.timePicker.disableRelativeTime();
// Guard against prior tests leaving the mode atom on "Around a time".
await searchPage.timePicker.selectRangeMode();
});
test('should preserve manually typed Start and End times', async () => {
const start = '2026-04-15 14:37:42';
const end = '2026-04-15 15:09:11';
await test.step('Type the datetimes into the inputs', async () => {
await searchPage.timePicker.fillStartDate(start);
await searchPage.timePicker.fillEndDate(end);
});
await test.step('Inputs retain the typed values before applying', async () => {
await expect(searchPage.timePicker.startDateInput).toHaveValue(start);
await expect(searchPage.timePicker.endDateInput).toHaveValue(end);
});
await test.step('Apply and confirm times propagate to the picker input', async () => {
await searchPage.timePicker.apply();
// The main picker input re-formats the range via date-fns, so the
// literal YYYY-MM-DD HH:mm:ss string won't appear. Instead, assert
// the typed HH:mm:ss components are present and the range is NOT
// collapsed to two midnights (the old broken behavior).
const inputValue = await searchPage.timePicker.input.inputValue();
expect(inputValue).toContain('14:37:42');
expect(inputValue).toContain('15:09:11');
});
await test.step('URL reflects an absolute range', async () => {
await searchPage.page.waitForURL('**/search**from=**to=**');
const url = searchPage.page.url();
expect(url).toContain('from=');
expect(url).toContain('to=');
expect(url).toContain('isLive=false');
});
});
test('should preserve typed times after closing and reopening the picker', async () => {
const start = '2026-04-10 09:15:30';
const end = '2026-04-10 11:45:20';
await searchPage.timePicker.fillStartDate(start);
await searchPage.timePicker.fillEndDate(end);
await searchPage.timePicker.apply();
await test.step('Reopen picker and verify inputs still show typed times', async () => {
await searchPage.timePicker.open();
await expect(searchPage.timePicker.startDateInput).toHaveValue(start);
await expect(searchPage.timePicker.endDateInput).toHaveValue(end);
});
});
test('should accept chrono natural-language times with non-midnight hours', async () => {
// "dateParser" in utils.ts uses chrono-node; this spec guards that natural
// language input still resolves to a non-midnight time after the fix.
await searchPage.timePicker.fillStartDate('yesterday at 3:22 pm');
// Format produced by the DateInput re-serialization is YYYY-MM-DD HH:mm:ss
// (24h). 3:22 pm → 15:22.
await expect(searchPage.timePicker.startDateInput).toHaveValue(/15:22/);
});
});