fix: limit inferred dates to the past (#572)

When parsing a date with inferred parts, this commit changes the parser to assume that the intended date was in the past. This impacts individual date fields and date range fields.
This commit is contained in:
Dan Hable 2025-01-27 15:03:44 -06:00 committed by GitHub
parent 5c9dfda9fe
commit 1396c84256
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 168 additions and 50 deletions

View file

@ -46,7 +46,7 @@
"@uiw/codemirror-themes": "^4.23.3",
"@uiw/react-codemirror": "^4.23.3",
"bootstrap": "^5.1.3",
"chrono-node": "^2.5.0",
"chrono-node": "^2.7.8",
"classnames": "^2.3.1",
"crypto-js": "^4.2.0",
"date-fns": "^2.28.0",

View file

@ -0,0 +1,101 @@
import { dateParser, parseTimeRangeInput } from '../utils';
describe('dateParser', () => {
beforeEach(() => {
// Mock current date to ensure consistent test results
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-15T22:00'));
});
afterEach(() => {
jest.useRealTimers();
});
it('returns null for undefined input', () => {
expect(dateParser(undefined)).toBeNull();
});
it('returns null for empty string', () => {
expect(dateParser('')).toBeNull();
});
it('parses absolute date', () => {
expect(dateParser('2024-01-15')).toEqual(new Date('2024-01-15T12:00:00'));
});
it('parses month/date at specific time correctly', () => {
expect(dateParser('Jan 15 13:12:00')).toEqual(
new Date('2025-01-15T13:12:00'),
);
});
it('parses future numeric month/date with prior year', () => {
expect(dateParser('01/31')).toEqual(new Date('2024-01-31T12:00:00'));
});
it('parses non-future numeric month/date with current year', () => {
expect(dateParser('01/15')).toEqual(new Date('2025-01-15T12:00:00'));
});
it('parses future month name/date with prior year', () => {
expect(dateParser('Jan 31')).toEqual(new Date('2024-01-31T12:00:00'));
});
it('parses non-future month name/date with current year', () => {
expect(dateParser('Jan 15')).toEqual(new Date('2025-01-15T12:00:00'));
});
});
describe('parseTimeRangeInput', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-01-15T22:00'));
});
afterEach(() => {
jest.useRealTimers();
});
it('returns [null, null] for empty string', () => {
expect(parseTimeRangeInput('')).toEqual([null, null]);
});
it('returns [null, null] for invalid input', () => {
expect(parseTimeRangeInput('invalid input')).toEqual([null, null]);
});
it('parses a range fully before the current time correctly', () => {
expect(parseTimeRangeInput('Jan 2 - Jan 10')).toEqual([
new Date('2025-01-02T12:00:00'),
new Date('2025-01-10T12:00:00'),
]);
});
it('parses a range with an implied start date in the previous year', () => {
expect(parseTimeRangeInput('Jan 31 - Jan 15')).toEqual([
new Date('2024-01-31T12:00:00'),
new Date('2025-01-15T12:00:00'),
]);
});
});
it('parses a range with specific times correctly', () => {
expect(parseTimeRangeInput('Jan 31 12:00:00 - Jan 14 13:05:29')).toEqual([
new Date('2024-01-31T12:00:00'),
new Date('2025-01-14T13:05:29'),
]);
});
it('parses single date correctly', () => {
expect(parseTimeRangeInput('2024-01-13')).toEqual([
new Date('2024-01-13T12:00:00'),
new Date(),
]);
});
it('parses explicit date range correctly', () => {
expect(parseTimeRangeInput('2024-01-15 to 2024-01-16')).toEqual([
new Date('2024-01-15T12:00:00'),
new Date('2024-01-16T12:00:00'),
]);
});

View file

@ -1,23 +1,63 @@
import * as chrono from 'chrono-node';
export function parseTimeRangeInput(str: string): [Date | null, Date | null] {
const parsedTimeResult = chrono.parse(str);
const start =
parsedTimeResult.length === 1
? parsedTimeResult[0].start?.date()
: parsedTimeResult.length > 1
? parsedTimeResult[1].start?.date()
: null;
const end =
parsedTimeResult.length === 1 && parsedTimeResult[0].end != null
? parsedTimeResult[0].end.date()
: parsedTimeResult.length > 1 && parsedTimeResult[1].end != null
? parsedTimeResult[1].end.date()
: start != null && start instanceof Date
? new Date()
: null;
function normalizeParsedDate(parsed?: chrono.ParsedComponents): Date | null {
if (!parsed) {
return null;
}
return [start, end];
if (parsed.isCertain('year')) {
return parsed.date();
}
const now = new Date();
if (
!(
parsed.isCertain('hour') ||
parsed.isCertain('minute') ||
parsed.isCertain('second') ||
parsed.isCertain('millisecond')
)
) {
// If all of the time components have been inferred, set the time components of now
// to match the parsed time components. This ensures that the comparison later on uses
// the same point in time when only worrying about dates.
now.setHours(parsed.get('hour') || 0);
now.setMinutes(parsed.get('minute') || 0);
now.setSeconds(parsed.get('second') || 0);
now.setMilliseconds(parsed.get('millisecond') || 0);
}
const parsedDate = parsed.date();
if (parsedDate > now) {
parsedDate.setFullYear(parsedDate.getFullYear() - 1);
}
return parsedDate;
}
export function parseTimeRangeInput(
str: string,
isUTC: boolean = false,
): [Date | null, Date | null] {
const parsedTimeResults = chrono.parse(str, isUTC ? { timezone: 0 } : {});
if (parsedTimeResults.length === 0) {
return [null, null];
}
const parsedTimeResult =
parsedTimeResults.length === 1
? parsedTimeResults[0]
: parsedTimeResults[1];
const start = normalizeParsedDate(parsedTimeResult.start);
const end = normalizeParsedDate(parsedTimeResult.end) || new Date();
if (end && start && end < start) {
// For date range strings that omit years, the chrono parser will infer the year
// using the current year. This can cause the start date to be in the future, and
// returned as the end date instead of the start date. After normalizing the dates,
// we then need to swap the order to maintain a range from older to newer.
return [end, start];
} else {
return [start, end];
}
}
export const LIVE_TAIL_TIME_QUERY = 'Live Tail';
@ -72,5 +112,6 @@ export const dateParser = (input?: string) => {
if (!input) {
return null;
}
return chrono.casual.parseDate(input);
const parsed = chrono.casual.parse(input)[0];
return normalizeParsedDate(parsed?.start);
};

View file

@ -8,7 +8,6 @@ import {
useState,
} from 'react';
import { useRouter } from 'next/router';
import * as chrono from 'chrono-node';
import {
format,
formatDuration,
@ -27,6 +26,7 @@ import {
withDefault,
} from 'use-query-params';
import { parseTimeRangeInput } from './components/TimePicker/utils';
import { useUserPreferences } from './useUserPreferences';
import { usePrevious } from './utils';
@ -62,31 +62,7 @@ export function parseTimeQuery(
const end = startOfSecond(new Date());
return [sub(end, { minutes: 15 }), end];
}
const parsedTimeResult = chrono.parse(
timeQuery,
isUTC
? {
timezone: 0, // 0 minute offset, UTC
}
: {},
);
const start =
parsedTimeResult.length === 1
? parsedTimeResult[0].start?.date()
: parsedTimeResult.length > 1
? parsedTimeResult[1].start?.date()
: null;
const end =
parsedTimeResult.length === 1 && parsedTimeResult[0].end != null
? parsedTimeResult[0].end.date()
: parsedTimeResult.length > 1 && parsedTimeResult[1].end != null
? parsedTimeResult[1].end.date()
: start != null && start instanceof Date
? new Date()
: null;
return [start, end];
return parseTimeRangeInput(timeQuery, isUTC);
}
export function parseValidTimeRange(

View file

@ -4348,7 +4348,7 @@ __metadata:
"@uiw/codemirror-themes": "npm:^4.23.3"
"@uiw/react-codemirror": "npm:^4.23.3"
bootstrap: "npm:^5.1.3"
chrono-node: "npm:^2.5.0"
chrono-node: "npm:^2.7.8"
classnames: "npm:^2.3.1"
crypto-js: "npm:^4.2.0"
date-fns: "npm:^2.28.0"
@ -12642,12 +12642,12 @@ __metadata:
languageName: node
linkType: hard
"chrono-node@npm:^2.5.0":
version: 2.5.0
resolution: "chrono-node@npm:2.5.0"
"chrono-node@npm:^2.7.8":
version: 2.7.8
resolution: "chrono-node@npm:2.7.8"
dependencies:
dayjs: "npm:^1.10.0"
checksum: 10c0/1569fa2353b12f38aa81b8cea3498dac7cb19ecde075221c60aaa1743a4133cd0fd2b874033d0da44308e4e8fe07d5e6daf1fb338cf531dad3e3c20355db8e53
checksum: 10c0/734af27b9cfa6aff34e41c2ec3f532a015ecb078241ab9c6a25e7503a3297109cd3503d1b74813ce453c850bdb45bf525c5b5961f35f307da2952d1ff49109ea
languageName: node
linkType: hard