Use daemon timestamps for turn timing (#1033)
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatDuration, formatMessageTimestamp } from "./time";
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("renders static durations as integers", () => {
|
||||
expect(formatDuration(5_600)).toBe("5s");
|
||||
expect(formatDuration(0)).toBe("0s");
|
||||
});
|
||||
|
||||
it("renders 10s-60s as whole seconds", () => {
|
||||
expect(formatDuration(47_000)).toBe("47s");
|
||||
expect(formatDuration(10_400)).toBe("10s");
|
||||
});
|
||||
|
||||
it("renders minutes and remainder seconds", () => {
|
||||
expect(formatDuration(132_000)).toBe("2m 12s");
|
||||
expect(formatDuration(120_000)).toBe("2m");
|
||||
});
|
||||
|
||||
it("renders hours and remainder minutes", () => {
|
||||
expect(formatDuration(3_900_000)).toBe("1h 5m");
|
||||
expect(formatDuration(3_600_000)).toBe("1h");
|
||||
});
|
||||
|
||||
it("guards against negative and NaN", () => {
|
||||
expect(formatDuration(-1)).toBe("0s");
|
||||
expect(formatDuration(Number.NaN)).toBe("0s");
|
||||
});
|
||||
|
||||
it("keeps one decimal for live durations", () => {
|
||||
expect(formatDuration(5_600, { mode: "live" })).toBe("5.6s");
|
||||
expect(formatDuration(12_340, { mode: "live" })).toBe("12.3s");
|
||||
expect(formatDuration(75_230, { mode: "live" })).toBe("1m 15.2s");
|
||||
expect(formatDuration(0, { mode: "live" })).toBe("0.0s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMessageTimestamp", () => {
|
||||
it("shows only time for same-day timestamps", () => {
|
||||
const now = new Date(2026, 4, 14, 17, 30);
|
||||
const date = new Date(2026, 4, 14, 12, 23);
|
||||
const formatted = formatMessageTimestamp(date, now);
|
||||
expect(formatted).toMatch(/12:23/);
|
||||
expect(formatted).not.toMatch(/Thursday|Wednesday/);
|
||||
});
|
||||
|
||||
it("includes weekday for timestamps within the last 6 days", () => {
|
||||
// 2026-05-14 is a Thursday. 2026-05-11 is a Monday.
|
||||
const now = new Date(2026, 4, 14, 17, 30);
|
||||
const date = new Date(2026, 4, 11, 22, 12);
|
||||
const formatted = formatMessageTimestamp(date, now);
|
||||
expect(formatted).toMatch(/Monday/);
|
||||
expect(formatted).toMatch(/10:12 PM|22:12/);
|
||||
});
|
||||
|
||||
it("includes full date for older timestamps", () => {
|
||||
const now = new Date(2026, 4, 14, 17, 30);
|
||||
const date = new Date(2026, 3, 1, 9, 5);
|
||||
const formatted = formatMessageTimestamp(date, now);
|
||||
expect(formatted).toMatch(/Apr|April/);
|
||||
expect(formatted).toMatch(/2026/);
|
||||
});
|
||||
});
|
||||
@@ -35,3 +35,87 @@ export function formatTimeAgo(date: Date): string {
|
||||
const day = date.getDate();
|
||||
return `${month} ${day}`;
|
||||
}
|
||||
|
||||
function isSameLocalDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a chat-message timestamp for hover-revealed UI.
|
||||
* - Same day: "10:11 PM"
|
||||
* - Within ~6 days: "Wednesday 10:11 PM"
|
||||
* - Older: "14 May 2026, 10:11 PM"
|
||||
*/
|
||||
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
||||
const time = date.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
if (isSameLocalDay(date, now)) {
|
||||
return time;
|
||||
}
|
||||
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays >= 0 && diffDays < 7) {
|
||||
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
|
||||
return `${weekday} ${time}`;
|
||||
}
|
||||
|
||||
const dateLabel = date.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
return `${dateLabel}, ${time}`;
|
||||
}
|
||||
|
||||
type DurationFormatMode = "static" | "live";
|
||||
|
||||
/**
|
||||
* Format a duration as a compact human-readable string.
|
||||
* - Static: integer units only
|
||||
* - Live: always keep one decimal in the smallest displayed unit
|
||||
*/
|
||||
export function formatDuration(
|
||||
durationMs: number,
|
||||
options?: { mode?: DurationFormatMode },
|
||||
): string {
|
||||
const mode = options?.mode ?? "static";
|
||||
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
||||
return mode === "live" ? "0.0s" : "0s";
|
||||
}
|
||||
const totalSeconds = durationMs / 1000;
|
||||
|
||||
if (mode === "live") {
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds.toFixed(1)}s`;
|
||||
}
|
||||
if (totalSeconds < 60 * 60) {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds - minutes * 60;
|
||||
return `${minutes}m ${seconds.toFixed(1)}s`;
|
||||
}
|
||||
const hours = Math.floor(totalSeconds / (60 * 60));
|
||||
const minutes = (totalSeconds - hours * 60 * 60) / 60;
|
||||
return `${hours}h ${minutes.toFixed(1)}m`;
|
||||
}
|
||||
|
||||
const wholeSeconds = Math.floor(totalSeconds);
|
||||
if (wholeSeconds < 60) {
|
||||
return `${wholeSeconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(wholeSeconds / 60);
|
||||
if (minutes < 60) {
|
||||
const seconds = wholeSeconds % 60;
|
||||
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMinutes = minutes % 60;
|
||||
return remMinutes === 0 ? `${hours}h` : `${hours}h ${remMinutes}m`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user