Skip to content

Commit 1396c84

Browse files
authored
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.
1 parent 5c9dfda commit 1396c84

File tree

5 files changed

+168
-50
lines changed

5 files changed

+168
-50
lines changed

packages/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"@uiw/codemirror-themes": "^4.23.3",
4747
"@uiw/react-codemirror": "^4.23.3",
4848
"bootstrap": "^5.1.3",
49-
"chrono-node": "^2.5.0",
49+
"chrono-node": "^2.7.8",
5050
"classnames": "^2.3.1",
5151
"crypto-js": "^4.2.0",
5252
"date-fns": "^2.28.0",
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { dateParser, parseTimeRangeInput } from '../utils';
2+
3+
describe('dateParser', () => {
4+
beforeEach(() => {
5+
// Mock current date to ensure consistent test results
6+
jest.useFakeTimers();
7+
jest.setSystemTime(new Date('2025-01-15T22:00'));
8+
});
9+
10+
afterEach(() => {
11+
jest.useRealTimers();
12+
});
13+
14+
it('returns null for undefined input', () => {
15+
expect(dateParser(undefined)).toBeNull();
16+
});
17+
18+
it('returns null for empty string', () => {
19+
expect(dateParser('')).toBeNull();
20+
});
21+
22+
it('parses absolute date', () => {
23+
expect(dateParser('2024-01-15')).toEqual(new Date('2024-01-15T12:00:00'));
24+
});
25+
26+
it('parses month/date at specific time correctly', () => {
27+
expect(dateParser('Jan 15 13:12:00')).toEqual(
28+
new Date('2025-01-15T13:12:00'),
29+
);
30+
});
31+
32+
it('parses future numeric month/date with prior year', () => {
33+
expect(dateParser('01/31')).toEqual(new Date('2024-01-31T12:00:00'));
34+
});
35+
36+
it('parses non-future numeric month/date with current year', () => {
37+
expect(dateParser('01/15')).toEqual(new Date('2025-01-15T12:00:00'));
38+
});
39+
40+
it('parses future month name/date with prior year', () => {
41+
expect(dateParser('Jan 31')).toEqual(new Date('2024-01-31T12:00:00'));
42+
});
43+
44+
it('parses non-future month name/date with current year', () => {
45+
expect(dateParser('Jan 15')).toEqual(new Date('2025-01-15T12:00:00'));
46+
});
47+
});
48+
49+
describe('parseTimeRangeInput', () => {
50+
beforeEach(() => {
51+
jest.useFakeTimers();
52+
jest.setSystemTime(new Date('2025-01-15T22:00'));
53+
});
54+
55+
afterEach(() => {
56+
jest.useRealTimers();
57+
});
58+
59+
it('returns [null, null] for empty string', () => {
60+
expect(parseTimeRangeInput('')).toEqual([null, null]);
61+
});
62+
63+
it('returns [null, null] for invalid input', () => {
64+
expect(parseTimeRangeInput('invalid input')).toEqual([null, null]);
65+
});
66+
67+
it('parses a range fully before the current time correctly', () => {
68+
expect(parseTimeRangeInput('Jan 2 - Jan 10')).toEqual([
69+
new Date('2025-01-02T12:00:00'),
70+
new Date('2025-01-10T12:00:00'),
71+
]);
72+
});
73+
74+
it('parses a range with an implied start date in the previous year', () => {
75+
expect(parseTimeRangeInput('Jan 31 - Jan 15')).toEqual([
76+
new Date('2024-01-31T12:00:00'),
77+
new Date('2025-01-15T12:00:00'),
78+
]);
79+
});
80+
});
81+
82+
it('parses a range with specific times correctly', () => {
83+
expect(parseTimeRangeInput('Jan 31 12:00:00 - Jan 14 13:05:29')).toEqual([
84+
new Date('2024-01-31T12:00:00'),
85+
new Date('2025-01-14T13:05:29'),
86+
]);
87+
});
88+
89+
it('parses single date correctly', () => {
90+
expect(parseTimeRangeInput('2024-01-13')).toEqual([
91+
new Date('2024-01-13T12:00:00'),
92+
new Date(),
93+
]);
94+
});
95+
96+
it('parses explicit date range correctly', () => {
97+
expect(parseTimeRangeInput('2024-01-15 to 2024-01-16')).toEqual([
98+
new Date('2024-01-15T12:00:00'),
99+
new Date('2024-01-16T12:00:00'),
100+
]);
101+
});

packages/app/src/components/TimePicker/utils.ts

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,63 @@
11
import * as chrono from 'chrono-node';
22

3-
export function parseTimeRangeInput(str: string): [Date | null, Date | null] {
4-
const parsedTimeResult = chrono.parse(str);
5-
const start =
6-
parsedTimeResult.length === 1
7-
? parsedTimeResult[0].start?.date()
8-
: parsedTimeResult.length > 1
9-
? parsedTimeResult[1].start?.date()
10-
: null;
11-
const end =
12-
parsedTimeResult.length === 1 && parsedTimeResult[0].end != null
13-
? parsedTimeResult[0].end.date()
14-
: parsedTimeResult.length > 1 && parsedTimeResult[1].end != null
15-
? parsedTimeResult[1].end.date()
16-
: start != null && start instanceof Date
17-
? new Date()
18-
: null;
3+
function normalizeParsedDate(parsed?: chrono.ParsedComponents): Date | null {
4+
if (!parsed) {
5+
return null;
6+
}
7+
8+
if (parsed.isCertain('year')) {
9+
return parsed.date();
10+
}
11+
12+
const now = new Date();
13+
if (
14+
!(
15+
parsed.isCertain('hour') ||
16+
parsed.isCertain('minute') ||
17+
parsed.isCertain('second') ||
18+
parsed.isCertain('millisecond')
19+
)
20+
) {
21+
// If all of the time components have been inferred, set the time components of now
22+
// to match the parsed time components. This ensures that the comparison later on uses
23+
// the same point in time when only worrying about dates.
24+
now.setHours(parsed.get('hour') || 0);
25+
now.setMinutes(parsed.get('minute') || 0);
26+
now.setSeconds(parsed.get('second') || 0);
27+
now.setMilliseconds(parsed.get('millisecond') || 0);
28+
}
1929

20-
return [start, end];
30+
const parsedDate = parsed.date();
31+
if (parsedDate > now) {
32+
parsedDate.setFullYear(parsedDate.getFullYear() - 1);
33+
}
34+
return parsedDate;
35+
}
36+
37+
export function parseTimeRangeInput(
38+
str: string,
39+
isUTC: boolean = false,
40+
): [Date | null, Date | null] {
41+
const parsedTimeResults = chrono.parse(str, isUTC ? { timezone: 0 } : {});
42+
if (parsedTimeResults.length === 0) {
43+
return [null, null];
44+
}
45+
46+
const parsedTimeResult =
47+
parsedTimeResults.length === 1
48+
? parsedTimeResults[0]
49+
: parsedTimeResults[1];
50+
const start = normalizeParsedDate(parsedTimeResult.start);
51+
const end = normalizeParsedDate(parsedTimeResult.end) || new Date();
52+
if (end && start && end < start) {
53+
// For date range strings that omit years, the chrono parser will infer the year
54+
// using the current year. This can cause the start date to be in the future, and
55+
// returned as the end date instead of the start date. After normalizing the dates,
56+
// we then need to swap the order to maintain a range from older to newer.
57+
return [end, start];
58+
} else {
59+
return [start, end];
60+
}
2161
}
2262

2363
export const LIVE_TAIL_TIME_QUERY = 'Live Tail';
@@ -72,5 +112,6 @@ export const dateParser = (input?: string) => {
72112
if (!input) {
73113
return null;
74114
}
75-
return chrono.casual.parseDate(input);
115+
const parsed = chrono.casual.parse(input)[0];
116+
return normalizeParsedDate(parsed?.start);
76117
};

packages/app/src/timeQuery.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
useState,
99
} from 'react';
1010
import { useRouter } from 'next/router';
11-
import * as chrono from 'chrono-node';
1211
import {
1312
format,
1413
formatDuration,
@@ -27,6 +26,7 @@ import {
2726
withDefault,
2827
} from 'use-query-params';
2928

29+
import { parseTimeRangeInput } from './components/TimePicker/utils';
3030
import { useUserPreferences } from './useUserPreferences';
3131
import { usePrevious } from './utils';
3232

@@ -62,31 +62,7 @@ export function parseTimeQuery(
6262
const end = startOfSecond(new Date());
6363
return [sub(end, { minutes: 15 }), end];
6464
}
65-
66-
const parsedTimeResult = chrono.parse(
67-
timeQuery,
68-
isUTC
69-
? {
70-
timezone: 0, // 0 minute offset, UTC
71-
}
72-
: {},
73-
);
74-
const start =
75-
parsedTimeResult.length === 1
76-
? parsedTimeResult[0].start?.date()
77-
: parsedTimeResult.length > 1
78-
? parsedTimeResult[1].start?.date()
79-
: null;
80-
const end =
81-
parsedTimeResult.length === 1 && parsedTimeResult[0].end != null
82-
? parsedTimeResult[0].end.date()
83-
: parsedTimeResult.length > 1 && parsedTimeResult[1].end != null
84-
? parsedTimeResult[1].end.date()
85-
: start != null && start instanceof Date
86-
? new Date()
87-
: null;
88-
89-
return [start, end];
65+
return parseTimeRangeInput(timeQuery, isUTC);
9066
}
9167

9268
export function parseValidTimeRange(

yarn.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4348,7 +4348,7 @@ __metadata:
43484348
"@uiw/codemirror-themes": "npm:^4.23.3"
43494349
"@uiw/react-codemirror": "npm:^4.23.3"
43504350
bootstrap: "npm:^5.1.3"
4351-
chrono-node: "npm:^2.5.0"
4351+
chrono-node: "npm:^2.7.8"
43524352
classnames: "npm:^2.3.1"
43534353
crypto-js: "npm:^4.2.0"
43544354
date-fns: "npm:^2.28.0"
@@ -12642,12 +12642,12 @@ __metadata:
1264212642
languageName: node
1264312643
linkType: hard
1264412644

12645-
"chrono-node@npm:^2.5.0":
12646-
version: 2.5.0
12647-
resolution: "chrono-node@npm:2.5.0"
12645+
"chrono-node@npm:^2.7.8":
12646+
version: 2.7.8
12647+
resolution: "chrono-node@npm:2.7.8"
1264812648
dependencies:
1264912649
dayjs: "npm:^1.10.0"
12650-
checksum: 10c0/1569fa2353b12f38aa81b8cea3498dac7cb19ecde075221c60aaa1743a4133cd0fd2b874033d0da44308e4e8fe07d5e6daf1fb338cf531dad3e3c20355db8e53
12650+
checksum: 10c0/734af27b9cfa6aff34e41c2ec3f532a015ecb078241ab9c6a25e7503a3297109cd3503d1b74813ce453c850bdb45bf525c5b5961f35f307da2952d1ff49109ea
1265112651
languageName: node
1265212652
linkType: hard
1265312653

0 commit comments

Comments
 (0)