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-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/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..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 @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { isTemporalFilterType, isTemporalFilterValueValid, + localDateTimeParts, normalizeTemporalFilterValue, } from "./filter-value-utils"; @@ -39,6 +40,9 @@ 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("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); }); @@ -56,7 +60,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 +71,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 in the configured timezone", () => { + const value = "2026-09-08T09:30:45"; + expect(normalizeTemporalFilterValue("datetime", value, "America/Los_Angeles")).toBe( + "2026-09-08T16:30:45.000Z", + ); + }); + + 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", + }); }); 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..e839eac8 100644 --- a/packages/core/src/components/data-table/filter-value-utils.ts +++ b/packages/core/src/components/data-table/filter-value-utils.ts @@ -1,9 +1,16 @@ +import { + fromDate, + getLocalTimeZone, + parseAbsolute, + parseDateTime, + toZoned, +} from "@internationalized/date"; 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 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+)?)?$/; @@ -19,10 +26,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; @@ -36,21 +39,34 @@ 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"; } -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; 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 toValidDateTime(trimmedValue, timeZone) != null; case "date": return DATE_RE.test(trimmedValue); case "time": @@ -61,6 +77,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; @@ -75,9 +92,11 @@ export function normalizeTemporalFilterValue( return date ? formatLocalDate(date) : undefined; } case "datetime": { - if (LOCAL_DATETIME_RE.test(trimmed)) return trimmed; + const datetime = toValidDateTime(trimmed, timeZone); + if (datetime) return datetime.toDate().toISOString(); + if (DATETIME_RE.test(trimmed)) return undefined; 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 +115,29 @@ 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, + 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: `${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 b2d03208..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 @@ -484,6 +500,27 @@ 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: timeZoneWrapper, + }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + await user.click(await screen.findByRole("button", { name: /^Update$/ })); + + expect(control.addFilter).toHaveBeenCalledWith( + "publishedAt", + "eq", + "2025-01-01T18:30:00.000Z", + undefined, + ); + }); + it("preserves an active operator even when the column now restricts the allowlist", async () => { const user = userEvent.setup(); const control = makeControl({ @@ -585,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"); + }); }); // --------------------------------------------------------------------------- @@ -977,41 +1076,90 @@ 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(); + vi.stubEnv("TZ", "UTC"); + const value = "2025-01-01T10:30:00Z"; const control = makeControl({ - filters: [{ field: "publishedAt", operator: "eq", value: "2025-01-01T10:30:00Z" }], + 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 value — no free-text ISO field. + // seeded from the RFC 3339 instant in the configured AppShell timezone. expect(await screen.findByRole("group")).toBeDefined(); - expect(screen.getByDisplayValue("10:30")).toBeDefined(); + expect(screen.getByDisplayValue("02:30")).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(); + vi.stubEnv("TZ", "UTC"); 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, + wrapper: timeZoneWrapper, + }); + + await openValueEditor(user); + + 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", "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: [ + { + field: "publishedAt", + operator: "between", + value: { min: "2025-01-01T10:30:00", max: "2025-01-02T11:30:00" }, + }, + ], + }); + render(, { + wrapper: timeZoneWrapper, }); 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" })); - // 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", "between", { + 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 d83455e9..5963e9fd 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -11,10 +11,15 @@ 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 { 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,19 @@ 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 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 ? `${nextDate}T${nextTime || "00:00"}:00` : ""); + onChange( + nextDate + ? (normalizeTemporalFilterValue( + "datetime", + `${nextDate}T${nextTime || "00:00"}:00`, + shellTz.value, + ) ?? "") + : "", + ); }; return (
@@ -641,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 }, @@ -659,6 +671,7 @@ function PanelDateRangeInput({ t("filterBetweenFrom"), t("filterBetweenTo"), t, + shellTz.value, ); return ( @@ -725,6 +738,7 @@ function PanelValueEditor({ control: CollectionControl; }) { const t = useDataTableT(); + const shellTz = useTimeZone(); const config = column.filter; const field = config.field; const label = column.label ?? field; @@ -761,20 +775,24 @@ 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, "between", toAddFilterSubmittedValue(type, "between", draft)); + control.addFilter( + field, + "between", + toAddFilterSubmittedValue(type, "between", draft, shellTz.value), + ); return; } if (text.trim() === "") { control.removeFilter(field); return; } - if (!isAddFilterDraftValueValid(type, operator, text)) return; + if (!isAddFilterDraftValueValid(type, operator, text, shellTz.value)) return; 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, @@ -1043,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; @@ -1082,10 +1103,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 +1116,21 @@ 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 shellTz = useTimeZone(); + const { date: datePart, time: timePart } = localDateTimeParts(value, shellTz.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`, + shellTz.value, + ) ?? "") + : "", + ); }; return ( @@ -1141,6 +1167,7 @@ function FilterChip({ }) { const t = useDataTableT(); const { locale } = useResolvedLocale(); + const shellTz = useTimeZone(); const config = column.filter; const label = column.label ?? config.field; @@ -1162,12 +1189,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") { @@ -1175,6 +1217,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 }); @@ -1182,6 +1228,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, @@ -1190,12 +1243,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"; @@ -1882,6 +1943,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 => @@ -1912,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(() => { @@ -1927,10 +1991,18 @@ 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") { + 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: localValue, max: localValueMax, @@ -1941,14 +2013,30 @@ 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; + control.addFilter(config.field, localOp, value); + onClose(); + return; + } control.addFilter(config.field, localOp, localValue); } else { return; } } 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"; @@ -2047,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; @@ -2065,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; } @@ -2076,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() !== ""; } @@ -2085,6 +2177,7 @@ function toAddFilterSubmittedValue( type: FilterConfig["type"], operator: FilterOperator, value: AddFilterDraftValue, + timeZone?: string, ): unknown { if (type === "enum") { return Array.isArray(value) ? (value as string[]) : []; @@ -2110,25 +2203,38 @@ function toAddFilterSubmittedValue( if (trimmedMin === "" || trimmedMax === "") return undefined; - // temporal types - return { min: trimmedMin, max: trimmedMax }; + 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: trimmedMin, + max: trimmedMax, + }; } if (type === "number") { return Number(value); } - return String(value).trim(); + const trimmed = String(value).trim(); + return type === "datetime" + ? normalizeTemporalFilterValue("datetime", trimmed, timeZone) + : 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 +2334,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. */ -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; +/** Format an RFC 3339 datetime as a locale medium date + short time. */ +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", @@ -2240,7 +2345,8 @@ 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))); + timeZone, + }).format(new Date(normalized)); } catch { return iso; } @@ -2251,6 +2357,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 { @@ -2293,12 +2400,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") {