diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSort.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSort.spec.ts index 6168aed59a..471ce67cf3 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSort.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSort.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { simpleDataGridOrderByColumn, simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn } from "@/lib/dataGrid/dataGridSort"; +import { compareDataGridValues, simpleDataGridOrderByColumn, simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn } from "@/lib/dataGrid/dataGridSort"; describe("simpleDataGridOrderByColumn", () => { it.each([ @@ -46,3 +46,36 @@ describe("simpleDataGridOrderByMatchesSort", () => { expect(simpleDataGridOrderByMatchesSort('"name" ASC', "old_name", "asc")).toBe(false); }); }); + +describe("compareDataGridValues datetime ordering", () => { + it("orders space-separated timestamps with microsecond precision", () => { + expect(compareDataGridValues("2024-01-01 12:00:00.123456", "2024-01-01 12:00:00.500000")).toBeLessThan(0); + expect(compareDataGridValues("2024-01-01 12:00:00.500000", "2024-01-01 12:00:00.123456")).toBeGreaterThan(0); + }); + + it("orders dates before earlier-day late times across midnight", () => { + expect(compareDataGridValues("2024-01-02 00:00:00", "2024-01-01 23:59:59")).toBeGreaterThan(0); + }); + + it("normalizes numeric UTC offsets so equal instants compare equal", () => { + expect(compareDataGridValues("2024-01-01 12:00:00+08", "2024-01-01 13:00:00+09")).toBe(0); + expect(compareDataGridValues("2024-01-01 11:00:00+09", "2024-01-01 12:00:00+08")).toBeLessThan(0); + }); + + it("sorts PostgreSQL infinity sentinels beyond finite timestamps", () => { + expect(compareDataGridValues("infinity", "9999-12-31 23:59:59")).toBeGreaterThan(0); + expect(compareDataGridValues("-infinity", "0001-01-01 00:00:00")).toBeLessThan(0); + }); + + it("still parses ISO-T timestamps with Zulu suffix", () => { + expect(compareDataGridValues("2024-01-01T12:00:00Z", "2024-01-01T12:00:01Z")).toBeLessThan(0); + }); + + it("falls back to collation for non-datetime strings", () => { + expect(compareDataGridValues("zebra", "apple")).toBeGreaterThan(0); + }); + + it("keeps null values after populated datetime cells", () => { + expect(compareDataGridValues(null, "2024-01-01 00:00:00")).toBeGreaterThan(0); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridSort.ts b/apps/desktop/src/lib/dataGrid/dataGridSort.ts index b37eda65bd..7faad6daae 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridSort.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridSort.ts @@ -173,7 +173,24 @@ function compareNumbers(left: number, right: number): number { } function dateSortValue(value: string): number | null { - if (!/^\d{4}-\d{2}-\d{2}/.test(value)) return null; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; + const trimmed = value.trim(); + // PostgreSQL and KingbaseES render timestamps as `YYYY-MM-DD HH:MM:SS.ffffff` + // (space separator, microsecond precision, optional numeric UTC offset) plus + // the special values infinity/-infinity. Date.parse rejects several of those + // shapes depending on the engine, silently degrading time columns to + // lexicographic order (issue #7281), so parse them deterministically. + if (trimmed === "infinity") return Number.MAX_SAFE_INTEGER; + if (trimmed === "-infinity") return Number.MIN_SAFE_INTEGER; + const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}(?::?\d{2})?)?)?/.exec(trimmed); + if (!match) return null; + const [, year, month, day, hour = "0", minute = "0", second = "0", fraction, zone] = match; + let sortValue = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second)) + (fraction ? Number(`0.${fraction}`) * 1000 : 0); + if (zone && zone !== "Z") { + const zoneMatch = /^([+-])(\d{2}):?(\d{2})?$/.exec(zone); + if (zoneMatch) { + const offsetMinutes = Number(zoneMatch[2]) * 60 + Number(zoneMatch[3] ?? 0); + sortValue -= (zoneMatch[1] === "-" ? -1 : 1) * offsetMinutes * 60_000; + } + } + return Number.isFinite(sortValue) ? sortValue : null; }