From 4a27b6604cf2a4ca3a752bad72b390994cbf9a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ey=C3=BCp=20Can=20Akman?= Date: Wed, 20 May 2026 20:41:55 +0300 Subject: [PATCH] fix(admin): parse SQLite timestamps as UTC SQLite datetime('now') stores timestamps with no timezone suffix, which new Date() reads as local time. Revision and dashboard timestamps in the admin UI drifted by the viewer's offset. Closes #919 --- .changeset/fix-revision-date-utc.md | 5 ++++ .../admin/src/components/RevisionHistory.tsx | 4 +-- packages/admin/src/lib/utils.ts | 20 ++++++++++++- packages/admin/tests/lib/utils.test.ts | 28 ++++++++++++++++++- packages/admin/vitest.config.ts | 5 +++- 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-revision-date-utc.md diff --git a/.changeset/fix-revision-date-utc.md b/.changeset/fix-revision-date-utc.md new file mode 100644 index 000000000..1fbc9e75a --- /dev/null +++ b/.changeset/fix-revision-date-utc.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes admin timestamps shown in the wrong timezone on SQLite-backed sites. Stored timestamps without an explicit timezone are now parsed as UTC, so revision history and dashboard timestamps no longer drift by the viewer's offset. diff --git a/packages/admin/src/components/RevisionHistory.tsx b/packages/admin/src/components/RevisionHistory.tsx index 4338b2a40..273f0132c 100644 --- a/packages/admin/src/components/RevisionHistory.tsx +++ b/packages/admin/src/components/RevisionHistory.tsx @@ -14,7 +14,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { fetchRevisions, restoreRevision, type Revision } from "../lib/api"; -import { formatRelativeTime } from "../lib/utils"; +import { formatRelativeTime, parseTimestamp } from "../lib/utils"; import { ConfirmDialog } from "./ConfirmDialog"; // ============================================================================= @@ -85,7 +85,7 @@ interface RevisionHistoryProps { * Format a date as a full timestamp */ function formatFullDate(dateString: string): string { - return new Date(dateString).toLocaleString(undefined, { + return parseTimestamp(dateString).toLocaleString(undefined, { weekday: "short", year: "numeric", month: "short", diff --git a/packages/admin/src/lib/utils.ts b/packages/admin/src/lib/utils.ts index fee13d469..ccd547697 100644 --- a/packages/admin/src/lib/utils.ts +++ b/packages/admin/src/lib/utils.ts @@ -8,6 +8,10 @@ const NON_ALPHANUMERIC_HYPHEN_PATTERN = /[^a-z0-9-]/g; const MULTIPLE_HYPHENS_PATTERN = /-+/g; const LEADING_TRAILING_HYPHEN_PATTERN = /^-|-$/g; +// Regex patterns for parseTimestamp +const NAIVE_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/; +const TIMEZONE_DESIGNATOR_PATTERN = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/; + /** * Merge class names with Tailwind CSS support */ @@ -15,13 +19,27 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +/** + * Parse a timestamp string into a Date, treating values without a timezone as UTC. + * + * SQLite `datetime('now')` returns `YYYY-MM-DD HH:MM:SS` with no designator, which JavaScript would otherwise read as local time. + */ +export function parseTimestamp(value: string): Date { + const hasTime = NAIVE_DATETIME_PATTERN.test(value); + const hasZone = TIMEZONE_DESIGNATOR_PATTERN.test(value); + if (hasTime && !hasZone) { + return new Date(value.replace(" ", "T") + "Z"); + } + return new Date(value); +} + /** * Convert a string to a URL-friendly slug. * * Handles unicode by normalizing to NFD and stripping diacritics. */ export function formatRelativeTime(dateString: string): string { - const date = new Date(dateString); + const date = parseTimestamp(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSecs = Math.floor(diffMs / 1000); diff --git a/packages/admin/tests/lib/utils.test.ts b/packages/admin/tests/lib/utils.test.ts index 73df38b61..b062f4c3a 100644 --- a/packages/admin/tests/lib/utils.test.ts +++ b/packages/admin/tests/lib/utils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { cn, slugify } from "../../src/lib/utils"; +import { cn, parseTimestamp, slugify } from "../../src/lib/utils"; describe("slugify", () => { it("converts basic text to slug", () => { @@ -62,3 +62,29 @@ describe("cn", () => { expect(cn("foo", undefined, null, "bar")).toBe("foo bar"); }); }); + +describe("parseTimestamp", () => { + it("treats a SQLite datetime('now') value as UTC", () => { + expect(parseTimestamp("2026-05-03 17:26:23").toISOString()).toBe("2026-05-03T17:26:23.000Z"); + }); + + it("leaves a value with a Z designator unchanged", () => { + expect(parseTimestamp("2026-05-03T17:26:23.000Z").toISOString()).toBe( + "2026-05-03T17:26:23.000Z", + ); + }); + + it("leaves a value with a lowercase z designator unchanged", () => { + expect(parseTimestamp("2026-05-03T17:26:23z").toISOString()).toBe("2026-05-03T17:26:23.000Z"); + }); + + it("respects an explicit UTC offset", () => { + expect(parseTimestamp("2026-05-03T17:26:23+02:00").toISOString()).toBe( + "2026-05-03T15:26:23.000Z", + ); + }); + + it("treats a Postgres hour-only offset as already zoned", () => { + expect(parseTimestamp("2026-05-03 17:26:23+00").toISOString()).toBe("2026-05-03T17:26:23.000Z"); + }); +}); diff --git a/packages/admin/vitest.config.ts b/packages/admin/vitest.config.ts index 00423730e..1f2fc9f45 100644 --- a/packages/admin/vitest.config.ts +++ b/packages/admin/vitest.config.ts @@ -16,7 +16,10 @@ export default defineConfig({ setupFiles: ["./tests/setup.ts"], browser: { enabled: true, - provider: playwright(), + // Pin a non-UTC timezone so timestamp-parsing tests catch local-vs-UTC bugs. + provider: playwright({ + contextOptions: { timezoneId: "America/New_York" }, + }), instances: [{ browser: "chromium" }], headless: true, },