Skip to content
Merged
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
6 changes: 3 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ import {
import { cn } from "~/lib/utils";
import { useUiStateStore } from "~/uiStateStore";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";
import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";

import {
buildInlineTerminalContextText,
Expand Down Expand Up @@ -1038,7 +1038,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
{formatShortTimestamp(row.message.createdAt, ctx.timestampFormat)}
{formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)}
</TooltipTrigger>
<TooltipPopup>
{formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
Expand Down Expand Up @@ -1129,7 +1129,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
<TooltipTrigger
render={<p className="text-muted-foreground text-xs tabular-nums" />}
>
{formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)}
{formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)}
</TooltipTrigger>
<TooltipPopup>
{formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)}
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/timestampFormat.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import {
formatDayAwareTimestamp,
formatElapsedDurationLabel,
formatExpiresInLabel,
formatRelativeTime,
Expand Down Expand Up @@ -96,6 +97,55 @@ describe("formatExpiresInLabel", () => {
});
});

describe("formatDayAwareTimestamp", () => {
// Instants are built with the local-time Date constructor so the
// calendar-day boundaries hold in any test timezone or locale.
const iso = (y: number, monthIndex: number, d: number, h: number, mi: number) =>
new Date(y, monthIndex, d, h, mi).toISOString();
const now = new Date(2026, 7, 14, 12, 0).getTime();
const time = (isoDate: string) => formatShortTimestamp(isoDate, "12-hour");

it("shows time only for today", () => {
const messageAt = iso(2026, 7, 14, 9, 30);
expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(time(messageAt));
});

it("labels the previous calendar day as yesterday even when under 24h old", () => {
const messageAt = iso(2026, 7, 13, 23, 30);
const justPastMidnight = new Date(2026, 7, 14, 0, 30).getTime();
expect(formatDayAwareTimestamp(messageAt, "12-hour", justPastMidnight)).toBe(
`yesterday at ${time(messageAt)}`,
);
});

it("prefixes older same-year messages with the numeric date", () => {
const messageAt = iso(2026, 7, 12, 12, 34);
const datePart = new Intl.DateTimeFormat(undefined, {
month: "numeric",
day: "numeric",
}).format(new Date(messageAt));
expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(
`${datePart} ${time(messageAt)}`,
);
});

it("includes the year once the calendar year differs", () => {
const messageAt = iso(2025, 11, 31, 18, 0);
const datePart = new Intl.DateTimeFormat(undefined, {
month: "numeric",
day: "numeric",
year: "numeric",
}).format(new Date(messageAt));
expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(
`${datePart} ${time(messageAt)}`,
);
});

it("returns an empty string for invalid input", () => {
expect(formatDayAwareTimestamp("not-a-date", "12-hour", now)).toBe("");
});
});

describe("invalid timestamp inputs", () => {
it("returns an empty timestamp instead of throwing", () => {
expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow();
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/timestampFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,44 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp
return getTimestampFormatter(timestampFormat, false).format(date);
}

const numericDateFormatter = new Intl.DateTimeFormat(undefined, {
month: "numeric",
day: "numeric",
});
const numericDateWithYearFormatter = new Intl.DateTimeFormat(undefined, {
month: "numeric",
day: "numeric",
year: "numeric",
});

/**
* Chat timestamp that adds the date once the message is no longer from today:
* today `12:34 PM`, yesterday `yesterday at 12:34 PM`, older `8/13 12:34 PM`
* (locale digit order), with the year included once the calendar year differs.
* Boundaries are local calendar days, not 24-hour windows.
*/
export function formatDayAwareTimestamp(
isoDate: string,
timestampFormat: TimestampFormat,
nowMs: number = Date.now(),
): string {
const date = parseTimestampDate(isoDate);
if (!date) return "";
const time = getTimestampFormatter(timestampFormat, false).format(date);

const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
// Round so DST-shifted 23/25 hour days still count as whole days.
const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000);

if (dayDiff <= 0) return time;
if (dayDiff === 1) return `yesterday at ${time}`;
const dateFormatter =
date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter;
return `${dateFormatter.format(date)} ${time}`;
}

/**
* Format a relative time string from an ISO date.
* Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`
Expand Down
Loading