Skip to content
Open
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/fix-revision-date-utc.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/admin/src/components/RevisionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// =============================================================================
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 19 additions & 1 deletion packages/admin/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,38 @@ 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
*/
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);
Comment on lines +29 to +33
}

/**
* 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);
Expand Down
28 changes: 27 additions & 1 deletion packages/admin/tests/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
5 changes: 4 additions & 1 deletion packages/admin/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Loading