Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-clocks-wait.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/components/data-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
isTemporalFilterType,
isTemporalFilterValueValid,
localDateTimeParts,
normalizeTemporalFilterValue,
} from "./filter-value-utils";

Expand Down Expand Up @@ -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);
});
Expand All @@ -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,
Expand All @@ -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", () => {
Expand Down
68 changes: 54 additions & 14 deletions packages/core/src/components/data-table/filter-value-utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import {
fromDate,
getLocalTimeZone,
parseAbsolute,
parseDateTime,
toZoned,
} from "@internationalized/date";
import type { FilterConfig } from "@/types/collection";

export type TemporalFilterType = Extract<FilterConfig["type"], "datetime" | "date" | "time">;

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+)?)?$/;

Expand All @@ -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;
Expand All @@ -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":
Expand All @@ -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;

Expand All @@ -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;
Comment on lines 98 to +99
}
case "time": {
if (TIME_RE.test(trimmed)) return trimmed;
Expand All @@ -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: "" };
}
Loading
Loading