@marimo-team/frontend 0.25.3-dev2 → 0.25.3-dev4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/JsonOutput-DxHKOpPG.js +11 -0
- package/dist/assets/{agent-panel-D7kMZLG-.js → agent-panel-DdkVL-Kx.js} +1 -1
- package/dist/assets/{cell-editor-ADVfycpN.js → cell-editor-32lVhib7.js} +1 -1
- package/dist/assets/{chat-panel-DQ6HkWEF.js → chat-panel-DN9vsbgS.js} +1 -1
- package/dist/assets/{column-preview-Cm2D6vyX.js → column-preview-CHK_DNrs.js} +1 -1
- package/dist/assets/{command-palette-DTSQ7gOC.js → command-palette-Cf7WX-nr.js} +1 -1
- package/dist/assets/{dates-BbFQ2oE4.js → dates-CfmdTL6B.js} +1 -1
- package/dist/assets/{edit-page-ChcSgpke.js → edit-page-BT74CF4G.js} +4 -4
- package/dist/assets/{file-explorer-panel-BEEK5lcl.js → file-explorer-panel-BmekFu7T.js} +1 -1
- package/dist/assets/{home-page-DMHnsRxm.js → home-page-s3LUizSn.js} +1 -1
- package/dist/assets/{hooks-93IwHd2u.js → hooks-RnjuFmEx.js} +1 -1
- package/dist/assets/{index-s-M1OXjs.js → index-D-kY3FU1.js} +2 -2
- package/dist/assets/{layout-DmcNEu3P.js → layout-B_GMOySr.js} +3 -3
- package/dist/assets/{panels-CxVG8-v2.js → panels-Dn59o8ay.js} +1 -1
- package/dist/assets/{reveal-component-ltaepAqw.js → reveal-component-CLE6vB1Z.js} +1 -1
- package/dist/assets/{run-page-DF3c5cDk.js → run-page-Depd6lEE.js} +1 -1
- package/dist/assets/{scratchpad-panel-Dwd6W5PE.js → scratchpad-panel-lc6jmpjv.js} +1 -1
- package/dist/assets/{session-panel-VdDhEBa-.js → session-panel-BxS5hiG2.js} +1 -1
- package/dist/assets/{useNotebookActions-NHN6A-cN.js → useNotebookActions-DIc4xTzY.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/src/components/data-table/__tests__/context-menu.test.ts +176 -0
- package/src/components/data-table/__tests__/date-filter-inputs.test.tsx +24 -1
- package/src/components/data-table/context-menu.tsx +125 -26
- package/src/components/data-table/date-filter-inputs.tsx +15 -42
- package/src/components/data-table/utils.ts +18 -0
- package/src/utils/__tests__/dates.test.ts +116 -0
- package/src/utils/dates.ts +94 -0
- package/dist/assets/JsonOutput-D6HIIsCb.js +0 -11
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import type { DataType } from "@/core/kernel/messages";
|
|
4
|
+
import { assertNever } from "@/utils/assertNever";
|
|
5
|
+
import { copyToClipboard } from "@/utils/copy";
|
|
6
|
+
import {
|
|
7
|
+
parseLocalISODate,
|
|
8
|
+
parseLocalISODateTime,
|
|
9
|
+
parseTimeToDate,
|
|
10
|
+
} from "@/utils/dates";
|
|
11
|
+
import { Logger } from "@/utils/Logger";
|
|
3
12
|
import type { Cell } from "@tanstack/react-table";
|
|
4
13
|
import { useAtomValue } from "jotai";
|
|
5
14
|
import { CopyIcon, FilterIcon, SquareStack } from "lucide-react";
|
|
6
15
|
import type { RefObject } from "react";
|
|
7
16
|
import useEvent from "react-use-event-hook";
|
|
8
|
-
import { copyToClipboard } from "@/utils/copy";
|
|
9
|
-
import { Logger } from "@/utils/Logger";
|
|
10
17
|
import {
|
|
11
18
|
ContextMenu,
|
|
12
19
|
ContextMenuContent,
|
|
@@ -16,13 +23,94 @@ import {
|
|
|
16
23
|
ContextMenuTrigger,
|
|
17
24
|
} from "../ui/context-menu";
|
|
18
25
|
import { DATA_CELL_ID } from "./cell-utils";
|
|
19
|
-
import {
|
|
20
|
-
Filter,
|
|
21
|
-
isMembershipFilterType,
|
|
22
|
-
type MembershipFilterType,
|
|
23
|
-
} from "./filters";
|
|
26
|
+
import { type FilterType, Filter } from "./filters";
|
|
24
27
|
import { selectedCellsAtom } from "./range-focus/atoms";
|
|
25
|
-
import { getClipboardContent, getRawValue } from "./utils";
|
|
28
|
+
import { getClipboardContent, getRawValue, isNullishSentinel } from "./utils";
|
|
29
|
+
|
|
30
|
+
type Comparison = "eq" | "neq";
|
|
31
|
+
|
|
32
|
+
const symbolOp = {
|
|
33
|
+
eq: "==",
|
|
34
|
+
neq: "!=",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
const textOp = {
|
|
38
|
+
eq: "equals",
|
|
39
|
+
neq: "does_not_equal",
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
export function buildCellFilter({
|
|
43
|
+
type,
|
|
44
|
+
value,
|
|
45
|
+
comparison = "eq",
|
|
46
|
+
dataType,
|
|
47
|
+
dtype,
|
|
48
|
+
}: {
|
|
49
|
+
type: FilterType;
|
|
50
|
+
value: unknown;
|
|
51
|
+
comparison?: Comparison;
|
|
52
|
+
dataType?: DataType;
|
|
53
|
+
dtype?: string;
|
|
54
|
+
}) {
|
|
55
|
+
if (isNullishSentinel(value, dataType, dtype)) {
|
|
56
|
+
return { type, operator: comparison === "eq" ? "is_null" : "is_not_null" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
switch (type) {
|
|
60
|
+
case "number":
|
|
61
|
+
if (typeof value !== "number") {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return Filter.number({
|
|
66
|
+
operator: symbolOp[comparison],
|
|
67
|
+
value,
|
|
68
|
+
});
|
|
69
|
+
case "text":
|
|
70
|
+
return Filter.text({
|
|
71
|
+
operator: textOp[comparison],
|
|
72
|
+
text: value as string,
|
|
73
|
+
});
|
|
74
|
+
case "date": {
|
|
75
|
+
const date = parseLocalISODate(value as string);
|
|
76
|
+
if (!date) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return Filter.date({
|
|
80
|
+
operator: symbolOp[comparison],
|
|
81
|
+
value: date,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
case "datetime": {
|
|
85
|
+
const date = parseLocalISODateTime(value as string);
|
|
86
|
+
if (!date) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return Filter.datetime({
|
|
90
|
+
operator: symbolOp[comparison],
|
|
91
|
+
value: date,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
case "time": {
|
|
95
|
+
const time = parseTimeToDate(value as string);
|
|
96
|
+
if (!time) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return Filter.time({
|
|
100
|
+
operator: symbolOp[comparison],
|
|
101
|
+
value: time,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
case "boolean": {
|
|
105
|
+
const target = comparison === "eq" ? value : !(value as boolean);
|
|
106
|
+
return Filter.boolean({
|
|
107
|
+
operator: target ? "is_true" : "is_false",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
default:
|
|
111
|
+
assertNever(type);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
26
114
|
|
|
27
115
|
export const DataTableContextMenu = <TData,>({
|
|
28
116
|
contextMenuRef,
|
|
@@ -102,19 +190,34 @@ export const CellContextMenu = <TData,>({
|
|
|
102
190
|
|
|
103
191
|
const column = cell.column;
|
|
104
192
|
const filterType = column.columnDef.meta?.filterType;
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
193
|
+
const dataType = column.columnDef.meta?.dataType;
|
|
194
|
+
const dtype = column.columnDef.meta?.dtype;
|
|
195
|
+
|
|
196
|
+
const canFilter = Boolean(filterType && column.getCanFilter());
|
|
197
|
+
const buildFilter = (comparison: Comparison) =>
|
|
198
|
+
canFilter
|
|
199
|
+
? buildCellFilter({
|
|
200
|
+
type: filterType as FilterType,
|
|
201
|
+
value: rawValue,
|
|
202
|
+
comparison,
|
|
203
|
+
dataType,
|
|
204
|
+
dtype,
|
|
205
|
+
})
|
|
108
206
|
: undefined;
|
|
207
|
+
const eqFilter = buildFilter("eq");
|
|
208
|
+
const canFilterByValue = eqFilter !== undefined;
|
|
209
|
+
|
|
210
|
+
const handleFilterCellByValue = (comparison: Comparison) => {
|
|
211
|
+
if (!filterType) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const filter = comparison === "eq" ? eqFilter : buildFilter("neq");
|
|
216
|
+
|
|
217
|
+
if (!filter) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
109
220
|
|
|
110
|
-
const handleFilterCell = (
|
|
111
|
-
type: MembershipFilterType,
|
|
112
|
-
operator: "in" | "not_in",
|
|
113
|
-
) => {
|
|
114
|
-
const filter =
|
|
115
|
-
type === "number"
|
|
116
|
-
? Filter.number({ operator, values: [rawValue] })
|
|
117
|
-
: Filter.text({ operator, values: [rawValue] });
|
|
118
221
|
column.setFilterValue(filter);
|
|
119
222
|
};
|
|
120
223
|
|
|
@@ -130,18 +233,14 @@ export const CellContextMenu = <TData,>({
|
|
|
130
233
|
Copy selected cells
|
|
131
234
|
</ContextMenuItem>
|
|
132
235
|
)}
|
|
133
|
-
{
|
|
236
|
+
{canFilterByValue && (
|
|
134
237
|
<>
|
|
135
238
|
<ContextMenuSeparator />
|
|
136
|
-
<ContextMenuItem
|
|
137
|
-
onClick={() => handleFilterCell(membershipFilterType, "in")}
|
|
138
|
-
>
|
|
239
|
+
<ContextMenuItem onClick={() => handleFilterCellByValue("eq")}>
|
|
139
240
|
<FilterIcon className="mo-dropdown-icon h-3 w-3" />
|
|
140
241
|
Filter by this value
|
|
141
242
|
</ContextMenuItem>
|
|
142
|
-
<ContextMenuItem
|
|
143
|
-
onClick={() => handleFilterCell(membershipFilterType, "not_in")}
|
|
144
|
-
>
|
|
243
|
+
<ContextMenuItem onClick={() => handleFilterCellByValue("neq")}>
|
|
145
244
|
<FilterIcon className="mo-dropdown-icon h-3 w-3" />
|
|
146
245
|
Remove rows with this value
|
|
147
246
|
</ContextMenuItem>
|
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
dateToLocalISODate,
|
|
15
15
|
dateToLocalISODateTime,
|
|
16
16
|
dateToLocalISOTime,
|
|
17
|
+
parseLocalISODate,
|
|
18
|
+
parseLocalISODateTime,
|
|
19
|
+
parseTimeToDate,
|
|
17
20
|
} from "@/utils/dates";
|
|
18
21
|
import type { FilterType } from "./filters";
|
|
19
22
|
|
|
@@ -72,53 +75,23 @@ export function parsePastedDate(
|
|
|
72
75
|
return undefined;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (timeMatch) {
|
|
80
|
-
const [, hStr, mStr, sStr, ampm] = timeMatch;
|
|
81
|
-
let hour = Number.parseInt(hStr, 10);
|
|
82
|
-
const minute = Number.parseInt(mStr, 10);
|
|
83
|
-
const second = sStr ? Number.parseInt(sStr, 10) : 0;
|
|
84
|
-
if (ampm) {
|
|
85
|
-
const isPm = ampm.toLowerCase() === "pm";
|
|
86
|
-
if (hour === 12) {
|
|
87
|
-
hour = isPm ? 12 : 0;
|
|
88
|
-
} else if (isPm) {
|
|
89
|
-
hour += 12;
|
|
90
|
-
}
|
|
78
|
+
if (filterType === "time") {
|
|
79
|
+
const date = parseTimeToDate(trimmed);
|
|
80
|
+
if (date) {
|
|
81
|
+
return date;
|
|
91
82
|
}
|
|
92
|
-
return new Date(1970, 0, 1, hour, minute, second);
|
|
93
83
|
}
|
|
94
84
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Number.parseInt(m, 10) - 1,
|
|
101
|
-
Number.parseInt(d, 10),
|
|
102
|
-
);
|
|
85
|
+
if (filterType === "date") {
|
|
86
|
+
const date = parseLocalISODate(trimmed);
|
|
87
|
+
if (date) {
|
|
88
|
+
return date;
|
|
89
|
+
}
|
|
103
90
|
}
|
|
104
91
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// viewer's timezone.
|
|
109
|
-
const isoMatch = trimmed.match(
|
|
110
|
-
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
|
|
111
|
-
);
|
|
112
|
-
if (isoMatch) {
|
|
113
|
-
const [, y, mo, d, h, mi, s] = isoMatch;
|
|
114
|
-
return new Date(
|
|
115
|
-
Number.parseInt(y, 10),
|
|
116
|
-
Number.parseInt(mo, 10) - 1,
|
|
117
|
-
Number.parseInt(d, 10),
|
|
118
|
-
Number.parseInt(h, 10),
|
|
119
|
-
Number.parseInt(mi, 10),
|
|
120
|
-
s ? Number.parseInt(s, 10) : 0,
|
|
121
|
-
);
|
|
92
|
+
const isoDateTime = parseLocalISODateTime(trimmed);
|
|
93
|
+
if (isoDateTime) {
|
|
94
|
+
return isoDateTime;
|
|
122
95
|
}
|
|
123
96
|
|
|
124
97
|
const parsed = new Date(trimmed);
|
|
@@ -228,6 +228,24 @@ export function detectSentinel(
|
|
|
228
228
|
return null;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
type NullishSentinelType = Extract<CellValueSentinel["type"], "null" | "nat">;
|
|
232
|
+
|
|
233
|
+
const NULLISH_SENTINEL_TYPES: ReadonlySet<CellValueSentinel["type"]> =
|
|
234
|
+
new Set<NullishSentinelType>(["null", "nat"]);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* True when the cell value represents a missing value (null, NaT)
|
|
238
|
+
* rather than a real value that happens to look special.
|
|
239
|
+
*/
|
|
240
|
+
export function isNullishSentinel(
|
|
241
|
+
value: unknown,
|
|
242
|
+
dataType: DataType | undefined,
|
|
243
|
+
dtype?: string,
|
|
244
|
+
): boolean {
|
|
245
|
+
const sentinel = detectSentinel(value, dataType, dtype);
|
|
246
|
+
return sentinel !== null && NULLISH_SENTINEL_TYPES.has(sentinel.type);
|
|
247
|
+
}
|
|
248
|
+
|
|
231
249
|
/**
|
|
232
250
|
* Stringify an unknown value. Converts objects to JSON strings.
|
|
233
251
|
* @param opts.value - The value to stringify.
|
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
exactDateTime,
|
|
5
5
|
getDateFormat,
|
|
6
6
|
getShortTimeZone,
|
|
7
|
+
parseLocalISODate,
|
|
8
|
+
parseLocalISODateTime,
|
|
9
|
+
parseTimeToDate,
|
|
7
10
|
prettyDate,
|
|
8
11
|
timeAgo,
|
|
9
12
|
} from "../dates";
|
|
@@ -191,6 +194,119 @@ describe("dates", () => {
|
|
|
191
194
|
});
|
|
192
195
|
});
|
|
193
196
|
|
|
197
|
+
describe("parseTimeToDate", () => {
|
|
198
|
+
it("parses 24-hour times", () => {
|
|
199
|
+
expect(parseTimeToDate("14:30")).toEqual(new Date(1970, 0, 1, 14, 30));
|
|
200
|
+
expect(parseTimeToDate("14:30:05")).toEqual(
|
|
201
|
+
new Date(1970, 0, 1, 14, 30, 5),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("parses times with an am/pm suffix", () => {
|
|
206
|
+
expect(parseTimeToDate("2:30 PM")).toEqual(new Date(1970, 0, 1, 14, 30));
|
|
207
|
+
expect(parseTimeToDate("12:00 am")).toEqual(new Date(1970, 0, 1, 0, 0));
|
|
208
|
+
expect(parseTimeToDate("12:00 pm")).toEqual(new Date(1970, 0, 1, 12, 0));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("rejects an out-of-range hour instead of letting Date normalize it", () => {
|
|
212
|
+
// new Date(1970, 0, 1, 25, 0) would silently become 01:00 the next day.
|
|
213
|
+
expect(parseTimeToDate("25:00")).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("rejects an out-of-range minute instead of letting Date normalize it", () => {
|
|
217
|
+
// new Date(1970, 0, 1, 12, 99) would silently become 13:39.
|
|
218
|
+
expect(parseTimeToDate("12:99")).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("rejects an out-of-range second", () => {
|
|
222
|
+
expect(parseTimeToDate("12:30:99")).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("requires an hour from 1 to 12 with an am/pm suffix", () => {
|
|
226
|
+
expect(parseTimeToDate("0:30 am")).toBeUndefined();
|
|
227
|
+
expect(parseTimeToDate("13:30 pm")).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("parses milliseconds", () => {
|
|
231
|
+
expect(parseTimeToDate("14:30:05.123")).toEqual(
|
|
232
|
+
new Date(1970, 0, 1, 14, 30, 5, 123),
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("pads a short millisecond fraction and truncates a long one", () => {
|
|
237
|
+
expect(parseTimeToDate("14:30:05.5")).toEqual(
|
|
238
|
+
new Date(1970, 0, 1, 14, 30, 5, 500),
|
|
239
|
+
);
|
|
240
|
+
expect(parseTimeToDate("14:30:05.123456")).toEqual(
|
|
241
|
+
new Date(1970, 0, 1, 14, 30, 5, 123),
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("parseLocalISODate", () => {
|
|
247
|
+
it("parses a date-only string into local midnight", () => {
|
|
248
|
+
expect(parseLocalISODate("2024-01-15")).toEqual(new Date(2024, 0, 15));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("returns undefined for a non-date-only string", () => {
|
|
252
|
+
expect(parseLocalISODate("2024-01-15T00:00:00")).toBeUndefined();
|
|
253
|
+
expect(parseLocalISODate("")).toBeUndefined();
|
|
254
|
+
expect(parseLocalISODate("not-a-date")).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("returns undefined for a calendar date that doesn't exist", () => {
|
|
258
|
+
expect(parseLocalISODate("2024-02-30")).toBeUndefined();
|
|
259
|
+
expect(parseLocalISODate("2024-13-01")).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("parseLocalISODateTime", () => {
|
|
264
|
+
it("parses a 'T'-separated datetime into local wall-clock time", () => {
|
|
265
|
+
expect(parseLocalISODateTime("2024-01-15T08:30:00")).toEqual(
|
|
266
|
+
new Date(2024, 0, 15, 8, 30, 0),
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("parses a space-separated datetime", () => {
|
|
271
|
+
expect(parseLocalISODateTime("2024-01-15 08:30:00")).toEqual(
|
|
272
|
+
new Date(2024, 0, 15, 8, 30, 0),
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("defaults seconds to 0 when omitted", () => {
|
|
277
|
+
expect(parseLocalISODateTime("2024-01-15T08:30")).toEqual(
|
|
278
|
+
new Date(2024, 0, 15, 8, 30, 0),
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("ignores a trailing Z instead of shifting to UTC", () => {
|
|
283
|
+
expect(parseLocalISODateTime("2024-01-15T08:30:00Z")).toEqual(
|
|
284
|
+
new Date(2024, 0, 15, 8, 30, 0),
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("ignores a trailing UTC offset instead of shifting the time", () => {
|
|
289
|
+
expect(parseLocalISODateTime("2024-01-15T08:30:00+05:00")).toEqual(
|
|
290
|
+
new Date(2024, 0, 15, 8, 30, 0),
|
|
291
|
+
);
|
|
292
|
+
expect(parseLocalISODateTime("2024-01-15T08:30:00-0500")).toEqual(
|
|
293
|
+
new Date(2024, 0, 15, 8, 30, 0),
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("ignores sub-second precision", () => {
|
|
298
|
+
expect(parseLocalISODateTime("2024-01-15T08:30:00.123")).toEqual(
|
|
299
|
+
new Date(2024, 0, 15, 8, 30, 0),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("returns undefined for a date-only or unparsable string", () => {
|
|
304
|
+
expect(parseLocalISODateTime("2024-01-15")).toBeUndefined();
|
|
305
|
+
expect(parseLocalISODateTime("not-a-datetime")).toBeUndefined();
|
|
306
|
+
expect(parseLocalISODateTime("")).toBeUndefined();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
194
310
|
describe("getDateFormat", () => {
|
|
195
311
|
it("returns the correct format for date", () => {
|
|
196
312
|
expect(getDateFormat("2023-05-15")).toBe("yyyy-MM-dd");
|
package/src/utils/dates.ts
CHANGED
|
@@ -165,6 +165,44 @@ export function dateToLocalISOTime(d: Date): string {
|
|
|
165
165
|
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Parse a time string into a Date anchored at the Unix epoch day. Supports
|
|
170
|
+
* the backend's wire format (`HH:MM:SS`, as produced by `dateToLocalISOTime`)
|
|
171
|
+
* as well as looser pasted text: optional seconds, optional milliseconds,
|
|
172
|
+
* and an optional `am`/`pm` suffix (e.g. `"14:30"`, `"2:30 PM"`,
|
|
173
|
+
* `"14:30:00.123"`).
|
|
174
|
+
*/
|
|
175
|
+
export function parseTimeToDate(value: string): Date | undefined {
|
|
176
|
+
const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.(\d+))?\s*(am|pm)?$/i.exec(
|
|
177
|
+
value.trim(),
|
|
178
|
+
);
|
|
179
|
+
if (!match) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
const [, hStr, mStr, sStr, msStr, ampm] = match;
|
|
183
|
+
let hour = Number.parseInt(hStr, 10);
|
|
184
|
+
const minute = Number.parseInt(mStr, 10);
|
|
185
|
+
const second = sStr ? Number.parseInt(sStr, 10) : 0;
|
|
186
|
+
const ms = msStr ? Number.parseInt(msStr.padEnd(3, "0").slice(0, 3), 10) : 0;
|
|
187
|
+
if (minute > 59 || second > 59) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
if (ampm) {
|
|
191
|
+
if (hour < 1 || hour > 12) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
const isPm = ampm.toLowerCase() === "pm";
|
|
195
|
+
if (hour === 12) {
|
|
196
|
+
hour = isPm ? 12 : 0;
|
|
197
|
+
} else if (isPm) {
|
|
198
|
+
hour += 12;
|
|
199
|
+
}
|
|
200
|
+
} else if (hour > 23) {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
return new Date(1970, 0, 1, hour, minute, second, ms);
|
|
204
|
+
}
|
|
205
|
+
|
|
168
206
|
/**
|
|
169
207
|
* Format a Date as `YYYY-MM-DDTHH:MM:SS` (no timezone suffix) using local
|
|
170
208
|
* fields. See `dateToLocalISODate` for the rationale on local vs UTC.
|
|
@@ -173,6 +211,62 @@ export function dateToLocalISODateTime(d: Date): string {
|
|
|
173
211
|
return `${dateToLocalISODate(d)}T${dateToLocalISOTime(d)}`;
|
|
174
212
|
}
|
|
175
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Parse a `YYYY-MM-DD` string into a Date anchored at local midnight.
|
|
216
|
+
*
|
|
217
|
+
* Unlike `new Date("YYYY-MM-DD")`, which the spec parses as UTC midnight,
|
|
218
|
+
* this treats the string as a calendar date in the viewer's own timezone.
|
|
219
|
+
* Returns undefined if the string doesn't match, or encodes a calendar
|
|
220
|
+
* date that doesn't exist (e.g. month 13, Feb 30), since `Date` would
|
|
221
|
+
* otherwise silently roll those over into a different date.
|
|
222
|
+
*/
|
|
223
|
+
export function parseLocalISODate(value: string): Date | undefined {
|
|
224
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
|
|
225
|
+
if (!match) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
const [, yStr, mStr, dStr] = match;
|
|
229
|
+
const year = Number.parseInt(yStr, 10);
|
|
230
|
+
const month = Number.parseInt(mStr, 10);
|
|
231
|
+
const day = Number.parseInt(dStr, 10);
|
|
232
|
+
const date = new Date(year, month - 1, day);
|
|
233
|
+
if (
|
|
234
|
+
date.getFullYear() !== year ||
|
|
235
|
+
date.getMonth() !== month - 1 ||
|
|
236
|
+
date.getDate() !== day
|
|
237
|
+
) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
return date;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse an ISO-ish `YYYY-MM-DD[T ]HH:MM[:SS[.sss]][Z|+HH:MM]` datetime string
|
|
245
|
+
* as wall-clock local time, ignoring any trailing `Z` or UTC offset.
|
|
246
|
+
*
|
|
247
|
+
* This keeps pasted datetimes consistent with the date/time pickers' local-
|
|
248
|
+
* time basis: pasting `2024-01-15T08:30:00Z` displays `08:30` instead of
|
|
249
|
+
* being shifted by the viewer's timezone.
|
|
250
|
+
*/
|
|
251
|
+
export function parseLocalISODateTime(value: string): Date | undefined {
|
|
252
|
+
const match =
|
|
253
|
+
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/.exec(
|
|
254
|
+
value.trim(),
|
|
255
|
+
);
|
|
256
|
+
if (!match) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
const [, yStr, moStr, dStr, hStr, miStr, sStr] = match;
|
|
260
|
+
return new Date(
|
|
261
|
+
Number.parseInt(yStr, 10),
|
|
262
|
+
Number.parseInt(moStr, 10) - 1,
|
|
263
|
+
Number.parseInt(dStr, 10),
|
|
264
|
+
Number.parseInt(hStr, 10),
|
|
265
|
+
Number.parseInt(miStr, 10),
|
|
266
|
+
sStr ? Number.parseInt(sStr, 10) : 0,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
176
270
|
export const supportedDateFormats = ["yyyy", "yyyy-MM", "yyyy-MM-dd"] as const;
|
|
177
271
|
export type DateFormat = (typeof supportedDateFormats)[number];
|
|
178
272
|
|