From 9c71bca8c7be93de2c1943ab672b71bcd2058859 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 17 Sep 2026 22:47:13 +0900 Subject: [PATCH 1/7] fix(data-table): serialize datetime filters as RFC 3339 --- .changeset/bright-clocks-wait.md | 5 ++ docs/components/data-table.md | 4 +- .../data-table/filter-value-utils.test.ts | 20 ++++- .../data-table/filter-value-utils.ts | 28 +++--- .../components/data-table/toolbar.test.tsx | 65 ++++++++++++-- .../src/components/data-table/toolbar.tsx | 88 ++++++++++++------- 6 files changed, 152 insertions(+), 58 deletions(-) create mode 100644 .changeset/bright-clocks-wait.md diff --git a/.changeset/bright-clocks-wait.md b/.changeset/bright-clocks-wait.md new file mode 100644 index 00000000..14d4ff14 --- /dev/null +++ b/.changeset/bright-clocks-wait.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +Fix `DataTable.Filters` datetime values to serialize as RFC 3339 instants and display existing instants in the local timezone. diff --git a/docs/components/data-table.md b/docs/components/data-table.md index de8fc875..36aeaf38 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -677,7 +677,7 @@ The `filter` property on a column accepts the same base shape as `FilterConfig`, | ---------- | ------------------------- | ------------------------------------------------------------------------------------------------------------ | | `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` | | `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `datetime` | Datetime-local | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `datetime` | **DatePicker + time** | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `date` | **Calendar / DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** | | `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `enum` | Dropdown | `eq`, `ne`, `in`, `nin` | @@ -697,7 +697,7 @@ When the `between` operator is selected on a `number`, `datetime`, `date`, or `t | `lte` | _before_ | on or before (inclusive) | | `between` | _between_ | inclusive min–max range | -`gt` / `lt` / `ne` are intentionally dropped — the inclusive _after_ / _before_ cover the intent. The filter chip shows the value as a locale-formatted date (e.g. `15 Jun 2026`), and the picker resolves its locale/timezone from the AppShell context. (Only `date` is remapped this way; `datetime` and `time` keep the full numeric operator set and native inputs.) +`gt` / `lt` / `ne` are intentionally dropped — the inclusive _after_ / _before_ cover the intent. The filter chip shows the value as a locale-formatted date (e.g. `15 Jun 2026`), and the picker resolves its locale/timezone from the AppShell context. `datetime` keeps the full numeric operator set and combines a DatePicker with a native time input; it serializes the selected local date and time as an RFC 3339 instant. `time` keeps the full numeric operator set and its native time input. ### String Filter Case Sensitivity diff --git a/packages/core/src/components/data-table/filter-value-utils.test.ts b/packages/core/src/components/data-table/filter-value-utils.test.ts index de4145d0..8372edc2 100644 --- a/packages/core/src/components/data-table/filter-value-utils.test.ts +++ b/packages/core/src/components/data-table/filter-value-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { isTemporalFilterType, isTemporalFilterValueValid, + localDateTimeParts, normalizeTemporalFilterValue, } from "./filter-value-utils"; @@ -39,6 +40,7 @@ describe("filter-value-utils", () => { it("validates canonical temporal values", () => { expect(isTemporalFilterValueValid("date", "2026-09-08")).toBe(true); expect(isTemporalFilterValueValid("datetime", "2026-09-08T09:30:45")).toBe(true); + expect(isTemporalFilterValueValid("datetime", "2026-99-08T09:30:45")).toBe(false); expect(isTemporalFilterValueValid("time", "09:30")).toBe(true); expect(isTemporalFilterValueValid("time", "09:30:00")).toBe(false); }); @@ -56,7 +58,7 @@ describe("filter-value-utils", () => { expect(normalizeTemporalFilterValue("date", value)).toBe("2026-09-08"); }); - it("normalizes datetime values from local date/time parts", () => { + it("normalizes datetime values to RFC 3339 instants", () => { const value = mockDate({ year: 2026, month: 9, @@ -67,7 +69,21 @@ describe("filter-value-utils", () => { iso: "2026-09-08T00:30:45.000Z", }); - expect(normalizeTemporalFilterValue("datetime", value)).toBe("2026-09-08T09:30:45"); + expect(normalizeTemporalFilterValue("datetime", value)).toBe("2026-09-08T00:30:45.000Z"); + }); + + it("converts legacy local datetime strings to RFC 3339", () => { + const value = "2026-09-08T09:30:45"; + expect(normalizeTemporalFilterValue("datetime", value)).toBe(new Date(value).toISOString()); + }); + + it("derives picker values in the local timezone from an RFC 3339 datetime", () => { + const value = "2026-09-08T00:30:45.000Z"; + const date = new Date(value); + expect(localDateTimeParts(value)).toEqual({ + date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + time: `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`, + }); }); it("normalizes time strings with seconds to the editable HH:mm format", () => { diff --git a/packages/core/src/components/data-table/filter-value-utils.ts b/packages/core/src/components/data-table/filter-value-utils.ts index 199d4538..5144c1c5 100644 --- a/packages/core/src/components/data-table/filter-value-utils.ts +++ b/packages/core/src/components/data-table/filter-value-utils.ts @@ -2,7 +2,6 @@ import type { FilterConfig } from "@/types/collection"; export type TemporalFilterType = Extract; -const LOCAL_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const TIME_WITH_SECONDS_RE = /^((?:[01]\d|2[0-3]):[0-5]\d)(?::[0-5]\d(?:\.\d+)?)?$/; @@ -19,10 +18,6 @@ function formatLocalTime(value: Date): string { return `${pad2(value.getHours())}:${pad2(value.getMinutes())}`; } -function formatLocalDateTime(value: Date): string { - return `${formatLocalDate(value)}T${formatLocalTime(value)}:${pad2(value.getSeconds())}`; -} - function toValidDate(value: unknown): Date | null { if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value; @@ -46,10 +41,12 @@ export function isTemporalFilterValueValid(type: TemporalFilterType, value: stri switch (type) { case "datetime": - // The datetime editor emits a local "YYYY-MM-DDTHH:mm:ss" (no zone); a - // trailing Z or ±hh:mm offset is still accepted for externally-set values. - return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test( - trimmedValue, + // Legacy filters may be local datetimes; DataTable editors serialize new + // values as RFC 3339 instants. + return ( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test( + trimmedValue, + ) && toValidDate(trimmedValue) != null ); case "date": return DATE_RE.test(trimmedValue); @@ -75,9 +72,8 @@ export function normalizeTemporalFilterValue( return date ? formatLocalDate(date) : undefined; } case "datetime": { - if (LOCAL_DATETIME_RE.test(trimmed)) return trimmed; const date = toValidDate(trimmed); - return date ? formatLocalDateTime(date) : undefined; + return date ? date.toISOString() : undefined; } case "time": { if (TIME_RE.test(trimmed)) return trimmed; @@ -96,8 +92,16 @@ export function normalizeTemporalFilterValue( case "date": return formatLocalDate(date); case "datetime": - return formatLocalDateTime(date); + return date.toISOString(); case "time": return formatLocalTime(date); } } + +/** Convert a datetime value to the local date/time parts displayed by its picker. */ +export function localDateTimeParts(value: string): { date: string; time: string } { + const datetime = toValidDate(value); + return datetime + ? { date: formatLocalDate(datetime), time: formatLocalTime(datetime) } + : { date: "", time: "" }; +} diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index b2d03208..a0ced005 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -484,6 +484,24 @@ describe("AddFilterPanel", () => { }); }); + it("normalizes a legacy local datetime when re-applying it from the panel", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00" }], + }); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + await user.click(await screen.findByRole("button", { name: /^Update$/ })); + + expect(control.addFilter).toHaveBeenCalledWith( + "publishedAt", + "eq", + new Date(2025, 0, 1, 10, 30).toISOString(), + undefined, + ); + }); + it("preserves an active operator even when the column now restricts the allowlist", async () => { const user = userEvent.setup(); const control = makeControl({ @@ -977,10 +995,13 @@ describe("DateFilterEditor", () => { // --------------------------------------------------------------------------- describe("TemporalFilterEditor", () => { - it("renders a date picker + time box for datetime (seeded, no raw ISO textbox)", async () => { + it("hydrates the date picker + time box from an RFC 3339 datetime", async () => { const user = userEvent.setup(); + const value = "2025-01-01T10:30:00Z"; + const date = new Date(value); + const time = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; const control = makeControl({ - filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00Z" }], + filters: [{ field: "publishedAt", operator: "eq", value }], }); render(, { wrapper, @@ -989,15 +1010,15 @@ describe("TemporalFilterEditor", () => { await openValueEditor(user); // Date part is a segmented picker (a group), time part a native time box - // seeded from the value — no free-text ISO field. + // seeded from the RFC 3339 instant in the user's local timezone. expect(await screen.findByRole("group")).toBeDefined(); - expect(screen.getByDisplayValue("10:30")).toBeDefined(); + expect(screen.getByDisplayValue(time)).toBeDefined(); }); - it("combines the date + time box into an ISO datetime on Apply", async () => { + it("serializes the selected local datetime as an RFC 3339 instant on Apply", async () => { const user = userEvent.setup(); const control = makeControl({ - filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00Z" }], + filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00" }], }); render(, { wrapper, @@ -1008,8 +1029,36 @@ describe("TemporalFilterEditor", () => { fireEvent.change(await screen.findByDisplayValue("10:30"), { target: { value: "08:45" } }); await user.click(screen.getByRole("button", { name: "Apply" })); - // Date kept, time replaced, seconds defaulted → local ISO (no zone). - expect(control.addFilter).toHaveBeenCalledWith("publishedAt", "eq", "2025-01-01T08:45:00"); + expect(control.addFilter).toHaveBeenCalledWith( + "publishedAt", + "eq", + new Date(2025, 0, 1, 8, 45).toISOString(), + ); + }); + + it("serializes both datetime between bounds as RFC 3339 instants", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [ + { + field: "publishedAt", + operator: "between", + value: { min: "2025-01-01T10:30:00", max: "2025-01-02T11:30:00" }, + }, + ], + }); + render(, { wrapper }); + + await openValueEditor(user); + + fireEvent.change(await screen.findByDisplayValue("10:30"), { target: { value: "08:45" } }); + fireEvent.change(screen.getByDisplayValue("11:30"), { target: { value: "09:15" } }); + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(control.addFilter).toHaveBeenCalledWith("publishedAt", "between", { + min: new Date(2025, 0, 1, 8, 45).toISOString(), + max: new Date(2025, 0, 2, 9, 15).toISOString(), + }); }); it("Apply button calls addFilter with an HH:MM time", async () => { diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index d83455e9..e387c8a9 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -14,7 +14,12 @@ import { parseDate, DateFormatter } from "@internationalized/date"; import { useResolvedLocale } from "@/contexts/appshell-context"; import { DataTableColumnSettings } from "./column-settings"; import { useDataTableContext } from "./data-table-context"; -import { isTemporalFilterType, isTemporalFilterValueValid } from "./filter-value-utils"; +import { + isTemporalFilterType, + isTemporalFilterValueValid, + localDateTimeParts, + normalizeTemporalFilterValue, +} from "./filter-value-utils"; import { useDataTableT } from "./i18n"; import type { CollectionControl, @@ -569,9 +574,8 @@ function PanelDateInput({ /** * Single-datetime editor for the panel: the inline `Calendar` up front with a - * labelled time picker beneath it, bridging a local ISO `"YYYY-MM-DDTHH:mm:ss"` - * string. (The chip and the "between" range keep the compact date-picker + time - * box to stay short.) + * labelled time picker beneath it, bridging an RFC 3339 instant. (The chip and + * the "between" range keep the compact date-picker + time box to stay short.) */ function PanelDateTimeInput({ ariaLabel, @@ -583,12 +587,14 @@ function PanelDateTimeInput({ onChange: (value: string) => void; }) { const t = useDataTableT(); - const match = value.match(/^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2}))?/); - const datePart = match?.[1] ?? ""; - const timePart = match?.[2] ?? ""; - const calValue = /^\d{4}-\d{2}-\d{2}$/.test(datePart) ? parseDate(datePart) : null; + const { date: datePart, time: timePart } = localDateTimeParts(value); + const calValue = datePart ? parseDate(datePart) : null; const emit = (nextDate: string, nextTime: string) => { - onChange(nextDate ? `${nextDate}T${nextTime || "00:00"}:00` : ""); + onChange( + nextDate + ? (normalizeTemporalFilterValue("datetime", `${nextDate}T${nextTime || "00:00"}:00`) ?? "") + : "", + ); }; return (
@@ -1082,10 +1088,9 @@ function DateFilterPicker({ /** * Datetime filter input: the app-shell date `DatePicker` (calendar) paired with a - * native time box, bridging an ISO `"YYYY-MM-DDTHH:mm:ss"` string. Entering a full - * datetime by hand is awkward, so the date and time are picked separately and - * combined. This is a stopgap — it's replaced 1:1 once a dedicated DateTime picker - * component lands. + * native time box, bridging an RFC 3339 instant. Entering a full datetime by hand + * is awkward, so the date and time are picked separately and combined. This is a + * stopgap — it's replaced 1:1 once a dedicated DateTime picker component lands. */ function DateTimeFilterInput({ ariaLabel, @@ -1096,15 +1101,16 @@ function DateTimeFilterInput({ value: string; onChange: (value: string) => void; }) { - // Split "YYYY-MM-DDTHH:mm[:ss][Z]" into its date and "HH:mm" parts. - const match = value.match(/^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2}))?/); - const datePart = match?.[1] ?? ""; - const timePart = match?.[2] ?? ""; - const calValue = /^\d{4}-\d{2}-\d{2}$/.test(datePart) ? parseDate(datePart) : null; + const { date: datePart, time: timePart } = localDateTimeParts(value); + const calValue = datePart ? parseDate(datePart) : null; - // Emit a combined value only once a date is chosen; time defaults to midnight. + // Emit an RFC 3339 instant only once a date is chosen; time defaults to midnight. const emit = (nextDate: string, nextTime: string) => { - onChange(nextDate ? `${nextDate}T${nextTime || "00:00"}:00` : ""); + onChange( + nextDate + ? (normalizeTemporalFilterValue("datetime", `${nextDate}T${nextTime || "00:00"}:00`) ?? "") + : "", + ); }; return ( @@ -1932,8 +1938,14 @@ function TemporalFilterEditor({ if (!minValid || !maxValid) return; if (!isRangeOrdered(config.type, localValue, localValueMax)) return; control.addFilter(config.field, localOp, { - min: localValue, - max: localValueMax, + min: + config.type === "datetime" + ? normalizeTemporalFilterValue("datetime", localValue) + : localValue, + max: + config.type === "datetime" + ? normalizeTemporalFilterValue("datetime", localValueMax) + : localValueMax, }); } else { return; @@ -1942,7 +1954,13 @@ function TemporalFilterEditor({ if (localValue.trim() === "") { control.removeFilter(config.field); } else if (isTemporalFilterValueValid(config.type, localValue)) { - control.addFilter(config.field, localOp, localValue); + control.addFilter( + config.field, + localOp, + config.type === "datetime" + ? normalizeTemporalFilterValue("datetime", localValue) + : localValue, + ); } else { return; } @@ -2110,25 +2128,28 @@ function toAddFilterSubmittedValue( if (trimmedMin === "" || trimmedMax === "") return undefined; - // temporal types - return { min: trimmedMin, max: trimmedMax }; + return { + min: type === "datetime" ? normalizeTemporalFilterValue("datetime", trimmedMin) : trimmedMin, + max: type === "datetime" ? normalizeTemporalFilterValue("datetime", trimmedMax) : trimmedMax, + }; } if (type === "number") { return Number(value); } - return String(value).trim(); + const trimmed = String(value).trim(); + return type === "datetime" ? normalizeTemporalFilterValue("datetime", trimmed) : trimmed; } /** * Whether a "between" range's bounds are correctly ordered (min ≤ max). Numbers - * compare numerically; temporal ISO strings compare lexicographically (which - * matches chronological order for our `YYYY-MM-DD`, `HH:MM`, and RFC datetime - * formats). `min === max` is allowed — a valid single-point inclusive range. + * compare numerically; dates and times lexicographically; and datetime instants + * chronologically. `min === max` is allowed — a valid single-point inclusive range. * Assumes both bounds are already individually valid and non-empty. */ function isRangeOrdered(type: FilterConfig["type"], min: string, max: string): boolean { if (type === "number") return Number(min) <= Number(max); + if (type === "datetime") return new Date(min).getTime() <= new Date(max).getTime(); if (isTemporalFilterType(type)) return min <= max; return true; } @@ -2228,11 +2249,10 @@ function formatDateRange(minIso: string, maxIso: string, locale: string): string .join(" – "); } -/** Format a local "YYYY-MM-DDTHH:mm[:ss]" as a locale medium date + short time. */ +/** Format an RFC 3339 datetime as a locale medium date + short time. */ function formatDateTimeValue(iso: string, locale: string): string { - const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); - if (!m) return iso; - const [, y, mo, d, h, min] = m; + const datetime = new Date(iso); + if (Number.isNaN(datetime.getTime())) return iso; try { return new DateFormatter(locale, { year: "numeric", @@ -2240,7 +2260,7 @@ function formatDateTimeValue(iso: string, locale: string): string { day: "numeric", hour: "2-digit", minute: "2-digit", - }).format(new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(min))); + }).format(datetime); } catch { return iso; } From 8a1612eaeb7bdb796c306335e9008de50942faf6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:37:17 +0000 Subject: [PATCH 2/7] fix: address datetime filter review feedback Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com> --- .../data-table/filter-value-utils.test.ts | 18 +-- .../data-table/filter-value-utils.ts | 48 +++++-- .../components/data-table/toolbar.test.tsx | 131 +++++++++++++++--- .../src/components/data-table/toolbar.tsx | 122 ++++++++++++---- 4 files changed, 262 insertions(+), 57 deletions(-) diff --git a/packages/core/src/components/data-table/filter-value-utils.test.ts b/packages/core/src/components/data-table/filter-value-utils.test.ts index 8372edc2..b61ff205 100644 --- a/packages/core/src/components/data-table/filter-value-utils.test.ts +++ b/packages/core/src/components/data-table/filter-value-utils.test.ts @@ -41,6 +41,8 @@ describe("filter-value-utils", () => { expect(isTemporalFilterValueValid("date", "2026-09-08")).toBe(true); expect(isTemporalFilterValueValid("datetime", "2026-09-08T09:30:45")).toBe(true); expect(isTemporalFilterValueValid("datetime", "2026-99-08T09:30:45")).toBe(false); + expect(isTemporalFilterValueValid("datetime", "2026-02-30T09:30:45Z")).toBe(false); + expect(isTemporalFilterValueValid("datetime", "2026-01-01T24:00:00Z")).toBe(false); expect(isTemporalFilterValueValid("time", "09:30")).toBe(true); expect(isTemporalFilterValueValid("time", "09:30:00")).toBe(false); }); @@ -72,17 +74,17 @@ describe("filter-value-utils", () => { expect(normalizeTemporalFilterValue("datetime", value)).toBe("2026-09-08T00:30:45.000Z"); }); - it("converts legacy local datetime strings to RFC 3339", () => { + it("converts legacy local datetime strings to RFC 3339 in the configured timezone", () => { const value = "2026-09-08T09:30:45"; - expect(normalizeTemporalFilterValue("datetime", value)).toBe(new Date(value).toISOString()); + expect(normalizeTemporalFilterValue("datetime", value, "America/Los_Angeles")).toBe( + "2026-09-08T16:30:45.000Z", + ); }); - it("derives picker values in the local timezone from an RFC 3339 datetime", () => { - const value = "2026-09-08T00:30:45.000Z"; - const date = new Date(value); - expect(localDateTimeParts(value)).toEqual({ - date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - time: `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`, + it("derives picker values in the configured timezone from an RFC 3339 datetime", () => { + expect(localDateTimeParts("2026-09-08T16:30:45.000Z", "America/Los_Angeles")).toEqual({ + date: "2026-09-08", + time: "09:30", }); }); diff --git a/packages/core/src/components/data-table/filter-value-utils.ts b/packages/core/src/components/data-table/filter-value-utils.ts index 5144c1c5..0bba62d4 100644 --- a/packages/core/src/components/data-table/filter-value-utils.ts +++ b/packages/core/src/components/data-table/filter-value-utils.ts @@ -1,8 +1,16 @@ +import { + fromDate, + getLocalTimeZone, + parseAbsolute, + parseDateTime, + toZoned, +} from "@internationalized/date"; import type { FilterConfig } from "@/types/collection"; export type TemporalFilterType = Extract; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/; const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const TIME_WITH_SECONDS_RE = /^((?:[01]\d|2[0-3]):[0-5]\d)(?::[0-5]\d(?:\.\d+)?)?$/; @@ -31,6 +39,17 @@ function toValidDate(value: unknown): Date | null { return null; } +function toValidDateTime(value: string, timeZone: string) { + if (!DATETIME_RE.test(value)) return null; + try { + return /(?:Z|[+-]\d{2}:\d{2})$/.test(value) + ? parseAbsolute(value, timeZone) + : toZoned(parseDateTime(value), timeZone); + } catch { + return null; + } +} + export function isTemporalFilterType(type: FilterConfig["type"]): type is TemporalFilterType { return type === "datetime" || type === "date" || type === "time"; } @@ -43,11 +62,7 @@ export function isTemporalFilterValueValid(type: TemporalFilterType, value: stri case "datetime": // Legacy filters may be local datetimes; DataTable editors serialize new // values as RFC 3339 instants. - return ( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test( - trimmedValue, - ) && toValidDate(trimmedValue) != null - ); + return toValidDateTime(trimmedValue, getLocalTimeZone()) != null; case "date": return DATE_RE.test(trimmedValue); case "time": @@ -58,6 +73,7 @@ export function isTemporalFilterValueValid(type: TemporalFilterType, value: stri export function normalizeTemporalFilterValue( type: TemporalFilterType, value: unknown, + timeZone = getLocalTimeZone(), ): string | undefined { if (value == null || value === "") return undefined; @@ -72,6 +88,9 @@ export function normalizeTemporalFilterValue( return date ? formatLocalDate(date) : undefined; } case "datetime": { + const datetime = toValidDateTime(trimmed, timeZone); + if (datetime) return datetime.toDate().toISOString(); + if (DATETIME_RE.test(trimmed)) return undefined; const date = toValidDate(trimmed); return date ? date.toISOString() : undefined; } @@ -99,9 +118,22 @@ export function normalizeTemporalFilterValue( } /** Convert a datetime value to the local date/time parts displayed by its picker. */ -export function localDateTimeParts(value: string): { date: string; time: string } { - const datetime = toValidDate(value); +export function localDateTimeParts( + value: string, + timeZone = getLocalTimeZone(), +): { date: string; time: string } { + const datetime = + toValidDateTime(value, timeZone) ?? + (!DATETIME_RE.test(value) + ? (() => { + const date = toValidDate(value); + return date ? fromDate(date, timeZone) : null; + })() + : null); return datetime - ? { date: formatLocalDate(datetime), time: formatLocalTime(datetime) } + ? { + date: `${datetime.year}-${pad2(datetime.month)}-${pad2(datetime.day)}`, + time: `${pad2(datetime.hour)}:${pad2(datetime.minute)}`, + } : { date: "", time: "" }; } diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index a0ced005..2030c0d7 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -1,7 +1,9 @@ +import type { ReactNode } from "react"; import { afterEach, describe, it, expect, vi } from "vitest"; import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createAppShellWrapper } from "../../../tests/test-utils"; +import { AppShellConfigContext, buildConfigurations } from "@/contexts/appshell-context"; import { DataTable } from "./data-table"; import { useDataTable } from "./use-data-table"; import type { CollectionControl } from "@/types/collection"; @@ -9,6 +11,7 @@ import type { Column } from "./types"; afterEach(() => { cleanup(); + vi.unstubAllEnvs(); }); type TestRow = Record; @@ -63,6 +66,19 @@ function TestFilters({ } const wrapper = createAppShellWrapper("en"); +const timeZoneWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); // --------------------------------------------------------------------------- // Filter chip segment helpers @@ -486,10 +502,13 @@ describe("AddFilterPanel", () => { it("normalizes a legacy local datetime when re-applying it from the panel", async () => { const user = userEvent.setup(); + vi.stubEnv("TZ", "UTC"); const control = makeControl({ filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00" }], }); - render(, { wrapper }); + render(, { + wrapper: timeZoneWrapper, + }); await user.click(screen.getByRole("button", { name: /Add filter/ })); await user.click(await screen.findByRole("button", { name: /^Update$/ })); @@ -497,7 +516,7 @@ describe("AddFilterPanel", () => { expect(control.addFilter).toHaveBeenCalledWith( "publishedAt", "eq", - new Date(2025, 0, 1, 10, 30).toISOString(), + "2025-01-01T18:30:00.000Z", undefined, ); }); @@ -603,6 +622,68 @@ describe("FilterChip", () => { expect(control.removeFilter).toHaveBeenCalledWith("name"); }); + + it("normalizes legacy datetime values when switching between single-value operators", async () => { + const user = userEvent.setup(); + vi.stubEnv("TZ", "UTC"); + const control = makeControl({ + filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00" }], + }); + render(, { + wrapper: timeZoneWrapper, + }); + + await user.click(screen.getByRole("button", { name: "is" })); + await user.click(await screen.findByRole("button", { name: "greater than" })); + + expect(control.addFilter).toHaveBeenCalledWith( + "publishedAt", + "gt", + "2025-01-01T18:30:00.000Z", + undefined, + ); + }); + + it("normalizes legacy datetime values when switching from single-value to between", async () => { + const user = userEvent.setup(); + vi.stubEnv("TZ", "UTC"); + const control = makeControl({ + filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00" }], + }); + render(, { + wrapper: timeZoneWrapper, + }); + + await user.click(screen.getByRole("button", { name: "is" })); + await user.click(await screen.findByRole("button", { name: "is between" })); + + expect(control.addFilter).toHaveBeenCalledWith("publishedAt", "between", { + min: "2025-01-01T18:30:00.000Z", + max: "2025-01-01T18:30:00.000Z", + }); + }); + + it("normalizes legacy datetime values when switching from between to single-value", async () => { + const user = userEvent.setup(); + vi.stubEnv("TZ", "UTC"); + const control = makeControl({ + filters: [ + { + field: "publishedAt", + operator: "between", + value: { min: "2025-01-01T10:30:00", max: "2025-01-02T11:30:00" }, + }, + ], + }); + render(, { + wrapper: timeZoneWrapper, + }); + + await user.click(screen.getByRole("button", { name: "is between" })); + await user.click(await screen.findByRole("button", { name: "less than" })); + + expect(control.addFilter).toHaveBeenCalledWith("publishedAt", "lt", "2025-01-01T18:30:00.000Z"); + }); }); // --------------------------------------------------------------------------- @@ -997,31 +1078,31 @@ describe("DateFilterEditor", () => { describe("TemporalFilterEditor", () => { it("hydrates the date picker + time box from an RFC 3339 datetime", async () => { const user = userEvent.setup(); + vi.stubEnv("TZ", "UTC"); const value = "2025-01-01T10:30:00Z"; - const date = new Date(value); - const time = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; const control = makeControl({ filters: [{ field: "publishedAt", operator: "eq", value }], }); render(, { - wrapper, + wrapper: timeZoneWrapper, }); await openValueEditor(user); // Date part is a segmented picker (a group), time part a native time box - // seeded from the RFC 3339 instant in the user's local timezone. + // seeded from the RFC 3339 instant in the configured AppShell timezone. expect(await screen.findByRole("group")).toBeDefined(); - expect(screen.getByDisplayValue(time)).toBeDefined(); + expect(screen.getByDisplayValue("02:30")).toBeDefined(); }); it("serializes the selected local datetime as an RFC 3339 instant on Apply", async () => { const user = userEvent.setup(); + vi.stubEnv("TZ", "UTC"); const control = makeControl({ filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00" }], }); render(, { - wrapper, + wrapper: timeZoneWrapper, }); await openValueEditor(user); @@ -1029,15 +1110,12 @@ describe("TemporalFilterEditor", () => { fireEvent.change(await screen.findByDisplayValue("10:30"), { target: { value: "08:45" } }); await user.click(screen.getByRole("button", { name: "Apply" })); - expect(control.addFilter).toHaveBeenCalledWith( - "publishedAt", - "eq", - new Date(2025, 0, 1, 8, 45).toISOString(), - ); + expect(control.addFilter).toHaveBeenCalledWith("publishedAt", "eq", "2025-01-01T16:45:00.000Z"); }); it("serializes both datetime between bounds as RFC 3339 instants", async () => { const user = userEvent.setup(); + vi.stubEnv("TZ", "UTC"); const control = makeControl({ filters: [ { @@ -1047,7 +1125,9 @@ describe("TemporalFilterEditor", () => { }, ], }); - render(, { wrapper }); + render(, { + wrapper: timeZoneWrapper, + }); await openValueEditor(user); @@ -1056,11 +1136,30 @@ describe("TemporalFilterEditor", () => { await user.click(screen.getByRole("button", { name: "Apply" })); expect(control.addFilter).toHaveBeenCalledWith("publishedAt", "between", { - min: new Date(2025, 0, 1, 8, 45).toISOString(), - max: new Date(2025, 0, 2, 9, 15).toISOString(), + min: "2025-01-01T16:45:00.000Z", + max: "2025-01-02T17:15:00.000Z", }); }); + it("formats datetime chip values in the configured AppShell timezone", () => { + vi.stubEnv("TZ", "UTC"); + const expected = new Intl.DateTimeFormat("en", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZone: "America/Los_Angeles", + }).format(new Date("2025-01-01T10:30:00Z")); + const control = makeControl({ + filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00Z" }], + }); + render(, { + wrapper: timeZoneWrapper, + }); + + expect(screen.getByText(expected)).toBeDefined(); + }); it("Apply button calls addFilter with an HH:MM time", async () => { const user = userEvent.setup(); const control = makeControl({ diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index e387c8a9..146de9d0 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -11,7 +11,7 @@ import { DatePicker } from "@/components/date-field"; import { Calendar } from "@/components/calendar"; import { Tooltip } from "@/components/tooltip"; import { parseDate, DateFormatter } from "@internationalized/date"; -import { useResolvedLocale } from "@/contexts/appshell-context"; +import { useResolvedLocale, useTimeZone } from "@/contexts/appshell-context"; import { DataTableColumnSettings } from "./column-settings"; import { useDataTableContext } from "./data-table-context"; import { @@ -587,12 +587,17 @@ function PanelDateTimeInput({ onChange: (value: string) => void; }) { const t = useDataTableT(); - const { date: datePart, time: timePart } = localDateTimeParts(value); + const shellTz = useTimeZone(); + const { date: datePart, time: timePart } = localDateTimeParts(value, shellTz.value); const calValue = datePart ? parseDate(datePart) : null; const emit = (nextDate: string, nextTime: string) => { onChange( nextDate - ? (normalizeTemporalFilterValue("datetime", `${nextDate}T${nextTime || "00:00"}:00`) ?? "") + ? (normalizeTemporalFilterValue( + "datetime", + `${nextDate}T${nextTime || "00:00"}:00`, + shellTz.value, + ) ?? "") : "", ); }; @@ -647,11 +652,12 @@ function PanelDateRangeInput({ }) { const t = useDataTableT(); const { locale } = useResolvedLocale(); + const shellTz = useTimeZone(); const [active, setActive] = useState<"from" | "to">("from"); const fmt = (v: string) => { if (!v) return "—"; - return withTime ? formatDateTimeValue(v, locale) : formatDateValue(v, locale); + return withTime ? formatDateTimeValue(v, locale, shellTz.value) : formatDateValue(v, locale); }; const bounds = [ { key: "from" as const, label: t("filterBetweenFrom"), value: min, onChange: onChangeMin }, @@ -731,6 +737,7 @@ function PanelValueEditor({ control: CollectionControl; }) { const t = useDataTableT(); + const shellTz = useTimeZone(); const config = column.filter; const field = config.field; const label = column.label ?? field; @@ -769,7 +776,11 @@ function PanelValueEditor({ const draft: AddFilterDraftValue = [min, max]; if (!isAddFilterDraftValueValid(type, "between", draft)) return; if (!isRangeOrdered(type, min, max)) return; - control.addFilter(field, "between", toAddFilterSubmittedValue(type, "between", draft)); + control.addFilter( + field, + "between", + toAddFilterSubmittedValue(type, "between", draft, shellTz.value), + ); return; } if (text.trim() === "") { @@ -780,7 +791,7 @@ function PanelValueEditor({ control.addFilter( field, operator, - toAddFilterSubmittedValue(type, operator, text), + toAddFilterSubmittedValue(type, operator, text, shellTz.value), // Preserve the existing filter's case-sensitivity (the panel has no toggle; // the chip's string editor owns it) instead of silently clearing it. type === "string" ? { caseSensitive: filter?.caseSensitive ?? false } : undefined, @@ -1101,14 +1112,19 @@ function DateTimeFilterInput({ value: string; onChange: (value: string) => void; }) { - const { date: datePart, time: timePart } = localDateTimeParts(value); + const shellTz = useTimeZone(); + const { date: datePart, time: timePart } = localDateTimeParts(value, shellTz.value); const calValue = datePart ? parseDate(datePart) : null; // Emit an RFC 3339 instant only once a date is chosen; time defaults to midnight. const emit = (nextDate: string, nextTime: string) => { onChange( nextDate - ? (normalizeTemporalFilterValue("datetime", `${nextDate}T${nextTime || "00:00"}:00`) ?? "") + ? (normalizeTemporalFilterValue( + "datetime", + `${nextDate}T${nextTime || "00:00"}:00`, + shellTz.value, + ) ?? "") : "", ); }; @@ -1147,6 +1163,7 @@ function FilterChip({ }) { const t = useDataTableT(); const { locale } = useResolvedLocale(); + const shellTz = useTimeZone(); const config = column.filter; const label = column.label ?? config.field; @@ -1168,12 +1185,27 @@ function FilterChip({ const n = Number(v); return Number.isFinite(n) ? n : 0; }; + const toDateTime = (v: unknown) => normalizeTemporalFilterValue("datetime", v, shellTz.value); + const toDateTimeRange = (v: unknown) => { + const range = (v ?? {}) as { min?: unknown; max?: unknown }; + const min = toDateTime(range.min); + const max = toDateTime(range.max); + return min && max ? { min, max } : null; + }; const arity = (op: FilterOperator) => (op === "between" ? 2 : 1); if (arity(nextOp) === arity(filter.operator)) { + let value = filter.value; + if (config.type === "datetime") { + value = + filter.operator === "between" + ? toDateTimeRange(filter.value) + : toDateTime(filter.value); + } + if (config.type === "datetime" && value == null) return; control.addFilter( config.field, nextOp, - filter.value, + value, config.type === "string" && filter.caseSensitive ? { caseSensitive: true } : undefined, ); } else if (nextOp === "between") { @@ -1181,6 +1213,10 @@ function FilterChip({ if (config.type === "number") { const n = toNum(v); control.addFilter(config.field, nextOp, { min: n, max: n }); + } else if (config.type === "datetime") { + const s = toDateTime(v); + if (!s) return; + control.addFilter(config.field, nextOp, { min: s, max: s }); } else { const s = v == null ? "" : String(v); control.addFilter(config.field, nextOp, { min: s, max: s }); @@ -1188,6 +1224,13 @@ function FilterChip({ } else { const range = (filter.value ?? {}) as { min?: unknown; max?: unknown }; const lower = range.min ?? range.max ?? ""; + if (config.type === "datetime") { + const normalized = toDateTime(lower); + if (!normalized) return; + control.addFilter(config.field, nextOp, normalized); + setOpOpen(false); + return; + } control.addFilter( config.field, nextOp, @@ -1196,12 +1239,20 @@ function FilterChip({ } setOpOpen(false); }, - [control, config.field, config.type, filter.operator, filter.value, filter.caseSensitive], + [ + control, + config.field, + config.type, + filter.operator, + filter.value, + filter.caseSensitive, + shellTz.value, + ], ); const operators = getVisibleFilterOperators(config, filter.operator); const operatorLabel = getOperatorLabel(filter.operator, t, config.type); - const valueLabel = formatFilterValue(filter, config, t, locale, label); + const valueLabel = formatFilterValue(filter, config, t, locale, shellTz.value, label); const segment = "astw:flex astw:items-center astw:h-6 astw:px-2 astw:text-xs astw:whitespace-nowrap astw:outline-hidden"; @@ -1888,6 +1939,7 @@ function TemporalFilterEditor({ hideOperator?: boolean; }) { const t = useDataTableT(); + const shellTz = useTimeZone(); const { items: operatorItems, initial: initialOp } = resolveTemporalOperator( getVisibleFilterOperators(config, filter.operator).filter( (operator): operator is NumericTemporalOperator => @@ -1940,11 +1992,11 @@ function TemporalFilterEditor({ control.addFilter(config.field, localOp, { min: config.type === "datetime" - ? normalizeTemporalFilterValue("datetime", localValue) + ? normalizeTemporalFilterValue("datetime", localValue, shellTz.value) : localValue, max: config.type === "datetime" - ? normalizeTemporalFilterValue("datetime", localValueMax) + ? normalizeTemporalFilterValue("datetime", localValueMax, shellTz.value) : localValueMax, }); } else { @@ -1958,7 +2010,7 @@ function TemporalFilterEditor({ config.field, localOp, config.type === "datetime" - ? normalizeTemporalFilterValue("datetime", localValue) + ? normalizeTemporalFilterValue("datetime", localValue, shellTz.value) : localValue, ); } else { @@ -1966,7 +2018,16 @@ function TemporalFilterEditor({ } } onClose(); - }, [localValue, localValueMax, localOp, control, config.field, config.type, onClose]); + }, [ + localValue, + localValueMax, + localOp, + control, + config.field, + config.type, + onClose, + shellTz.value, + ]); const isDate = config.type === "date"; const isDateTime = config.type === "datetime"; @@ -2103,6 +2164,7 @@ function toAddFilterSubmittedValue( type: FilterConfig["type"], operator: FilterOperator, value: AddFilterDraftValue, + timeZone?: string, ): unknown { if (type === "enum") { return Array.isArray(value) ? (value as string[]) : []; @@ -2129,8 +2191,14 @@ function toAddFilterSubmittedValue( if (trimmedMin === "" || trimmedMax === "") return undefined; return { - min: type === "datetime" ? normalizeTemporalFilterValue("datetime", trimmedMin) : trimmedMin, - max: type === "datetime" ? normalizeTemporalFilterValue("datetime", trimmedMax) : trimmedMax, + min: + type === "datetime" + ? normalizeTemporalFilterValue("datetime", trimmedMin, timeZone) + : trimmedMin, + max: + type === "datetime" + ? normalizeTemporalFilterValue("datetime", trimmedMax, timeZone) + : trimmedMax, }; } @@ -2138,7 +2206,9 @@ function toAddFilterSubmittedValue( return Number(value); } const trimmed = String(value).trim(); - return type === "datetime" ? normalizeTemporalFilterValue("datetime", trimmed) : trimmed; + return type === "datetime" + ? normalizeTemporalFilterValue("datetime", trimmed, timeZone) + : trimmed; } /** @@ -2250,9 +2320,9 @@ function formatDateRange(minIso: string, maxIso: string, locale: string): string } /** Format an RFC 3339 datetime as a locale medium date + short time. */ -function formatDateTimeValue(iso: string, locale: string): string { - const datetime = new Date(iso); - if (Number.isNaN(datetime.getTime())) return iso; +function formatDateTimeValue(iso: string, locale: string, timeZone: string): string { + const normalized = normalizeTemporalFilterValue("datetime", iso, timeZone); + if (!normalized) return iso; try { return new DateFormatter(locale, { year: "numeric", @@ -2260,7 +2330,8 @@ function formatDateTimeValue(iso: string, locale: string): string { day: "numeric", hour: "2-digit", minute: "2-digit", - }).format(datetime); + timeZone, + }).format(new Date(normalized)); } catch { return iso; } @@ -2271,6 +2342,7 @@ function formatFilterValue( config: FilterConfig, t: ReturnType, locale: string, + timeZone: string, /** Column label — used to summarize multi-select enums as "N labels". */ label?: string, ): string { @@ -2313,12 +2385,12 @@ function formatFilterValue( if (filter.operator === "between") { const range = filter.value as { min?: unknown; max?: unknown } | null; if (!range || typeof range !== "object") return ""; - const min = range.min != null ? formatDateTimeValue(String(range.min), locale) : ""; - const max = range.max != null ? formatDateTimeValue(String(range.max), locale) : ""; + const min = range.min != null ? formatDateTimeValue(String(range.min), locale, timeZone) : ""; + const max = range.max != null ? formatDateTimeValue(String(range.max), locale, timeZone) : ""; return [min, max].filter(Boolean).join(" – "); } if (filter.value == null || filter.value === "") return ""; - return formatDateTimeValue(String(filter.value), locale); + return formatDateTimeValue(String(filter.value), locale, timeZone); } if (isTemporalFilterType(config.type) && filter.operator === "between") { From a2f6402fcc909d81f79c8b6aaf971c5969bc09c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:39:03 +0000 Subject: [PATCH 3/7] fix: guard invalid datetime range normalization Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com> --- .../core/src/components/data-table/toolbar.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 146de9d0..74a23b2f 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -2190,15 +2190,17 @@ function toAddFilterSubmittedValue( if (trimmedMin === "" || trimmedMax === "") return undefined; + if (type === "datetime") { + const normalizedMin = normalizeTemporalFilterValue("datetime", trimmedMin, timeZone); + const normalizedMax = normalizeTemporalFilterValue("datetime", trimmedMax, timeZone); + return normalizedMin && normalizedMax + ? { min: normalizedMin, max: normalizedMax } + : undefined; + } + return { - min: - type === "datetime" - ? normalizeTemporalFilterValue("datetime", trimmedMin, timeZone) - : trimmedMin, - max: - type === "datetime" - ? normalizeTemporalFilterValue("datetime", trimmedMax, timeZone) - : trimmedMax, + min: trimmedMin, + max: trimmedMax, }; } From 33343ff9cdd5389de789c59260b14b3820ac301c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:40:53 +0000 Subject: [PATCH 4/7] fix: guard datetime editor normalization Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com> --- .../src/components/data-table/toolbar.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 74a23b2f..de24e16c 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -1989,15 +1989,17 @@ function TemporalFilterEditor({ const maxValid = isTemporalFilterValueValid(config.type, localValueMax); if (!minValid || !maxValid) return; if (!isRangeOrdered(config.type, localValue, localValueMax)) return; + if (config.type === "datetime") { + const min = normalizeTemporalFilterValue("datetime", localValue, shellTz.value); + const max = normalizeTemporalFilterValue("datetime", localValueMax, shellTz.value); + if (!min || !max) return; + control.addFilter(config.field, localOp, { min, max }); + onClose(); + return; + } control.addFilter(config.field, localOp, { - min: - config.type === "datetime" - ? normalizeTemporalFilterValue("datetime", localValue, shellTz.value) - : localValue, - max: - config.type === "datetime" - ? normalizeTemporalFilterValue("datetime", localValueMax, shellTz.value) - : localValueMax, + min: localValue, + max: localValueMax, }); } else { return; @@ -2006,13 +2008,14 @@ function TemporalFilterEditor({ if (localValue.trim() === "") { control.removeFilter(config.field); } else if (isTemporalFilterValueValid(config.type, localValue)) { - control.addFilter( - config.field, - localOp, - config.type === "datetime" - ? normalizeTemporalFilterValue("datetime", localValue, shellTz.value) - : localValue, - ); + if (config.type === "datetime") { + const value = normalizeTemporalFilterValue("datetime", localValue, shellTz.value); + if (!value) return; + control.addFilter(config.field, localOp, value); + onClose(); + return; + } + control.addFilter(config.field, localOp, localValue); } else { return; } From fb645921b2cb7d0d846deebfd11431b5cdb147b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:41:57 +0000 Subject: [PATCH 5/7] fix: align datetime validation with app shell timezone Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com> --- .../core/src/components/data-table/filter-value-utils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/components/data-table/filter-value-utils.ts b/packages/core/src/components/data-table/filter-value-utils.ts index 0bba62d4..e839eac8 100644 --- a/packages/core/src/components/data-table/filter-value-utils.ts +++ b/packages/core/src/components/data-table/filter-value-utils.ts @@ -54,7 +54,11 @@ export function isTemporalFilterType(type: FilterConfig["type"]): type is Tempor return type === "datetime" || type === "date" || type === "time"; } -export function isTemporalFilterValueValid(type: TemporalFilterType, value: string): boolean { +export function isTemporalFilterValueValid( + type: TemporalFilterType, + value: string, + timeZone = getLocalTimeZone(), +): boolean { const trimmedValue = value.trim(); if (trimmedValue === "") return false; @@ -62,7 +66,7 @@ export function isTemporalFilterValueValid(type: TemporalFilterType, value: stri case "datetime": // Legacy filters may be local datetimes; DataTable editors serialize new // values as RFC 3339 instants. - return toValidDateTime(trimmedValue, getLocalTimeZone()) != null; + return toValidDateTime(trimmedValue, timeZone) != null; case "date": return DATE_RE.test(trimmedValue); case "time": From 78003cc5b363b31649d6f21b58fe33e7b8876056 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:50:12 +0000 Subject: [PATCH 6/7] fix: use app shell timezone in datetime validation Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com> --- .../src/components/data-table/toolbar.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index de24e16c..5963e9fd 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -671,6 +671,7 @@ function PanelDateRangeInput({ t("filterBetweenFrom"), t("filterBetweenTo"), t, + shellTz.value, ); return ( @@ -774,7 +775,7 @@ function PanelValueEditor({ } if (isBetween) { const draft: AddFilterDraftValue = [min, max]; - if (!isAddFilterDraftValueValid(type, "between", draft)) return; + if (!isAddFilterDraftValueValid(type, "between", draft, shellTz.value)) return; if (!isRangeOrdered(type, min, max)) return; control.addFilter( field, @@ -787,7 +788,7 @@ function PanelValueEditor({ control.removeFilter(field); return; } - if (!isAddFilterDraftValueValid(type, operator, text)) return; + if (!isAddFilterDraftValueValid(type, operator, text, shellTz.value)) return; control.addFilter( field, operator, @@ -1060,13 +1061,16 @@ function betweenOrderError( minLabel: string, maxLabel: string, t: ReturnType, + timeZone?: string, ): string | undefined { if (min.trim() === "" || max.trim() === "") return undefined; let bothValid = true; if (type === "number") { bothValid = !Number.isNaN(Number(min)) && !Number.isNaN(Number(max)); } else if (isTemporalFilterType(type)) { - bothValid = isTemporalFilterValueValid(type, min) && isTemporalFilterValueValid(type, max); + bothValid = + isTemporalFilterValueValid(type, min, timeZone) && + isTemporalFilterValueValid(type, max, timeZone); } if (!bothValid) return undefined; if (isRangeOrdered(type, min, max)) return undefined; @@ -1970,12 +1974,14 @@ function TemporalFilterEditor({ if (minEmpty && maxEmpty) return true; // will removeFilter if (minEmpty || maxEmpty) return false; // both required return ( - isTemporalFilterValueValid(config.type, localValue) && - isTemporalFilterValueValid(config.type, localValueMax) && + isTemporalFilterValueValid(config.type, localValue, shellTz.value) && + isTemporalFilterValueValid(config.type, localValueMax, shellTz.value) && isRangeOrdered(config.type, localValue, localValueMax) ); } - return localValue.trim() === "" || isTemporalFilterValueValid(config.type, localValue); + return ( + localValue.trim() === "" || isTemporalFilterValueValid(config.type, localValue, shellTz.value) + ); })(); const handleCommit = useCallback(() => { @@ -1985,8 +1991,8 @@ function TemporalFilterEditor({ if (minEmpty && maxEmpty) { control.removeFilter(config.field); } else if (!minEmpty && !maxEmpty) { - const minValid = isTemporalFilterValueValid(config.type, localValue); - const maxValid = isTemporalFilterValueValid(config.type, localValueMax); + const minValid = isTemporalFilterValueValid(config.type, localValue, shellTz.value); + const maxValid = isTemporalFilterValueValid(config.type, localValueMax, shellTz.value); if (!minValid || !maxValid) return; if (!isRangeOrdered(config.type, localValue, localValueMax)) return; if (config.type === "datetime") { @@ -2007,7 +2013,7 @@ function TemporalFilterEditor({ } else { if (localValue.trim() === "") { control.removeFilter(config.field); - } else if (isTemporalFilterValueValid(config.type, localValue)) { + } else if (isTemporalFilterValueValid(config.type, localValue, shellTz.value)) { if (config.type === "datetime") { const value = normalizeTemporalFilterValue("datetime", localValue, shellTz.value); if (!value) return; @@ -2129,6 +2135,7 @@ function isAddFilterDraftValueValid( type: FilterConfig["type"], operator: FilterOperator, value: AddFilterDraftValue, + timeZone?: string, ): boolean { if (type === "enum") { return Array.isArray(value) && value.length > 0; @@ -2147,7 +2154,10 @@ function isAddFilterDraftValueValid( return !Number.isNaN(Number(min)) && !Number.isNaN(Number(max)); } if (isTemporalFilterType(type)) { - return isTemporalFilterValueValid(type, min) && isTemporalFilterValueValid(type, max); + return ( + isTemporalFilterValueValid(type, min, timeZone) && + isTemporalFilterValueValid(type, max, timeZone) + ); } return true; } @@ -2158,7 +2168,7 @@ function isAddFilterDraftValueValid( return !Number.isNaN(Number(value)); } if (isTemporalFilterType(type)) { - return isTemporalFilterValueValid(type, value); + return isTemporalFilterValueValid(type, value, timeZone); } return value.trim() !== ""; } From 21d6b8c3a7702b7f0c3b8daa63ce9ca8e30d2c7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:37:57 +0000 Subject: [PATCH 7/7] docs: resync data-table generated docs Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com> --- docs-manifest.json | 8 ++++---- docs/components/data-table.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs-manifest.json b/docs-manifest.json index 63477342..b216a958 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -605,7 +605,7 @@ "hashes": { "typeSurface": "b1614970f950ceb6", "outline": "bda40376d6e3e008", - "snapshot": "d4fa33f2da19d5ee", + "snapshot": "b845d4c54453b0f0", "outputMd": "ec6a0ac098d1d87a", "examples": "934c52d4e8761404" } @@ -1013,7 +1013,7 @@ "hashes": { "typeSurface": "943616def28cafb0", "outline": "8f06df28129676c8", - "snapshot": "02dfd02003105939", + "snapshot": "6233cd9de511210f", "outputMd": "ceb4a05822538d20", "examples": "cff0508bb5520ae6" } @@ -1166,7 +1166,7 @@ "hashes": { "typeSurface": "429710973dfa912b", "outline": "721e8995f53f0e8f", - "snapshot": "f6fcbd73e127f2f4", + "snapshot": "0ceac9786fb4ce3c", "outputMd": "ae287e2bb5abc1c6", "examples": "1ce89cec30045466" } @@ -1183,7 +1183,7 @@ "hashes": { "typeSurface": "e760949efcbe4c38", "outline": "464d91acc48f7c9d", - "snapshot": "12c20fc6f44d1b4c", + "snapshot": "40d15c3f1003fdb0", "outputMd": "1f81ebd562c50371", "examples": "b0eebb8c5e2219dc" } diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 151130f7..6d5b14f7 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -697,7 +697,7 @@ The `filter` property on a column accepts the same base shape as `FilterConfig`, | ---------- | ------------------------- | ------------------------------------------------------------------------------------------------------------ | | `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` | | `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `datetime` | **DatePicker + time** | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `datetime` | Datetime-local | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `date` | **Calendar / DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** | | `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `enum` | Dropdown | `eq`, `ne`, `in`, `nin` | @@ -717,7 +717,7 @@ When the `between` operator is selected on a `number`, `datetime`, `date`, or `t | `lte` | _before_ | on or before (inclusive) | | `between` | _between_ | inclusive min–max range | -`gt` / `lt` / `ne` are intentionally dropped — the inclusive _after_ / _before_ cover the intent. The filter chip shows the value as a locale-formatted date (e.g. `15 Jun 2026`), and the picker resolves its locale/timezone from the AppShell context. `datetime` keeps the full numeric operator set and combines a DatePicker with a native time input; it serializes the selected local date and time as an RFC 3339 instant. `time` keeps the full numeric operator set and its native time input. +`gt` / `lt` / `ne` are intentionally dropped — the inclusive _after_ / _before_ cover the intent. The filter chip shows the value as a locale-formatted date (e.g. `15 Jun 2026`), and the picker resolves its locale/timezone from the AppShell context. (Only `date` is remapped this way; `datetime` and `time` keep the full numeric operator set and native inputs.) ### String Filter Case Sensitivity