Skip to content

Commit 48ddb3d

Browse files
t3dotggclaude
andauthored
feat(web): older chat timestamps show the date, not just the time (#6654)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 804cba4 commit 48ddb3d

3 files changed

Lines changed: 91 additions & 3 deletions

File tree

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ import {
105105
import { cn } from "~/lib/utils";
106106
import { useUiStateStore } from "~/uiStateStore";
107107
import { type TimestampFormat } from "@t3tools/contracts/settings";
108-
import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";
108+
import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
109109

110110
import {
111111
buildInlineTerminalContextText,
@@ -1038,7 +1038,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
10381038
<div className="flex shrink-0 items-center gap-2">
10391039
<Tooltip>
10401040
<TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
1041-
{formatShortTimestamp(row.message.createdAt, ctx.timestampFormat)}
1041+
{formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)}
10421042
</TooltipTrigger>
10431043
<TooltipPopup>
10441044
{formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
@@ -1129,7 +1129,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
11291129
<TooltipTrigger
11301130
render={<p className="text-muted-foreground text-xs tabular-nums" />}
11311131
>
1132-
{formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)}
1132+
{formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)}
11331133
</TooltipTrigger>
11341134
<TooltipPopup>
11351135
{formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)}

apps/web/src/timestampFormat.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22

33
import {
4+
formatDayAwareTimestamp,
45
formatElapsedDurationLabel,
56
formatExpiresInLabel,
67
formatRelativeTime,
@@ -96,6 +97,55 @@ describe("formatExpiresInLabel", () => {
9697
});
9798
});
9899

100+
describe("formatDayAwareTimestamp", () => {
101+
// Instants are built with the local-time Date constructor so the
102+
// calendar-day boundaries hold in any test timezone or locale.
103+
const iso = (y: number, monthIndex: number, d: number, h: number, mi: number) =>
104+
new Date(y, monthIndex, d, h, mi).toISOString();
105+
const now = new Date(2026, 7, 14, 12, 0).getTime();
106+
const time = (isoDate: string) => formatShortTimestamp(isoDate, "12-hour");
107+
108+
it("shows time only for today", () => {
109+
const messageAt = iso(2026, 7, 14, 9, 30);
110+
expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(time(messageAt));
111+
});
112+
113+
it("labels the previous calendar day as yesterday even when under 24h old", () => {
114+
const messageAt = iso(2026, 7, 13, 23, 30);
115+
const justPastMidnight = new Date(2026, 7, 14, 0, 30).getTime();
116+
expect(formatDayAwareTimestamp(messageAt, "12-hour", justPastMidnight)).toBe(
117+
`yesterday at ${time(messageAt)}`,
118+
);
119+
});
120+
121+
it("prefixes older same-year messages with the numeric date", () => {
122+
const messageAt = iso(2026, 7, 12, 12, 34);
123+
const datePart = new Intl.DateTimeFormat(undefined, {
124+
month: "numeric",
125+
day: "numeric",
126+
}).format(new Date(messageAt));
127+
expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(
128+
`${datePart} ${time(messageAt)}`,
129+
);
130+
});
131+
132+
it("includes the year once the calendar year differs", () => {
133+
const messageAt = iso(2025, 11, 31, 18, 0);
134+
const datePart = new Intl.DateTimeFormat(undefined, {
135+
month: "numeric",
136+
day: "numeric",
137+
year: "numeric",
138+
}).format(new Date(messageAt));
139+
expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(
140+
`${datePart} ${time(messageAt)}`,
141+
);
142+
});
143+
144+
it("returns an empty string for invalid input", () => {
145+
expect(formatDayAwareTimestamp("not-a-date", "12-hour", now)).toBe("");
146+
});
147+
});
148+
99149
describe("invalid timestamp inputs", () => {
100150
it("returns an empty timestamp instead of throwing", () => {
101151
expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow();

apps/web/src/timestampFormat.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,44 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp
9191
return getTimestampFormatter(timestampFormat, false).format(date);
9292
}
9393

94+
const numericDateFormatter = new Intl.DateTimeFormat(undefined, {
95+
month: "numeric",
96+
day: "numeric",
97+
});
98+
const numericDateWithYearFormatter = new Intl.DateTimeFormat(undefined, {
99+
month: "numeric",
100+
day: "numeric",
101+
year: "numeric",
102+
});
103+
104+
/**
105+
* Chat timestamp that adds the date once the message is no longer from today:
106+
* today `12:34 PM`, yesterday `yesterday at 12:34 PM`, older `8/13 12:34 PM`
107+
* (locale digit order), with the year included once the calendar year differs.
108+
* Boundaries are local calendar days, not 24-hour windows.
109+
*/
110+
export function formatDayAwareTimestamp(
111+
isoDate: string,
112+
timestampFormat: TimestampFormat,
113+
nowMs: number = Date.now(),
114+
): string {
115+
const date = parseTimestampDate(isoDate);
116+
if (!date) return "";
117+
const time = getTimestampFormatter(timestampFormat, false).format(date);
118+
119+
const now = new Date(nowMs);
120+
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
121+
const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
122+
// Round so DST-shifted 23/25 hour days still count as whole days.
123+
const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000);
124+
125+
if (dayDiff <= 0) return time;
126+
if (dayDiff === 1) return `yesterday at ${time}`;
127+
const dateFormatter =
128+
date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter;
129+
return `${dateFormatter.format(date)} ${time}`;
130+
}
131+
94132
/**
95133
* Format a relative time string from an ISO date.
96134
* Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`

0 commit comments

Comments
 (0)