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, },