-
{a.action}
-
- {[a.ip, a.device, a.app].filter(Boolean).join(" · ")}
+
+
+
{a.action}
+
+ {[a.ip, a.device, a.app].filter(Boolean).join(" · ")}
+
+
-
- {new Date(a.created_at).toLocaleDateString()}
-
))
)}
@@ -73,4 +79,4 @@ export function ActivityLogPanel() {
)}
);
-}
\ No newline at end of file
+}
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index aa3e9a9..66e587c 100644
--- a/src/lib/format.test.ts
+++ b/src/lib/format.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi, afterEach } from "vitest";
import {
clientActivity,
formatBytes,
+ formatDateTime,
isRecentTimestamp,
timestampMs,
} from "./format";
@@ -42,6 +43,20 @@ describe("timestampMs", () => {
});
});
+describe("formatDateTime", () => {
+ it("includes the clock time for ISO timestamps", () => {
+ expect(
+ formatDateTime("2026-08-28T14:35:00.000Z", "en-US", {
+ timeZone: "UTC",
+ })
+ ).toBe("Aug 28, 2026, 2:35 PM");
+ });
+
+ it("returns a placeholder for missing timestamps", () => {
+ expect(formatDateTime("")).toBe("—");
+ });
+});
+
describe("time-window helpers", () => {
afterEach(() => vi.useRealTimers());
diff --git a/src/lib/format.ts b/src/lib/format.ts
index ca54fe2..97c97e9 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -41,6 +41,24 @@ export function formatRelativeTime(input?: string | number | null): string {
return `${day}d ago`;
}
+/** Locale-aware date + time for audit/activity timestamps. */
+export function formatDateTime(
+ input?: string | number | null,
+ locale?: Intl.LocalesArgument,
+ options: Intl.DateTimeFormatOptions = {}
+): string {
+ const ms = timestampMs(input);
+ if (ms == null) return "—";
+ return new Intl.DateTimeFormat(locale, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ ...options,
+ }).format(new Date(ms));
+}
+
/** Pick label + timestamp for node liveness UI (matches VPN app logic). */
export function nodeActivityDisplay(
node: {