authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-14 22:48:08-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 22:48:08-04:00
log48ddb3d469d245363dba723f774ba449e4d950c5
tree2d8faa3761742be95844f9e56ae47a090f0171a2
parent804cba4305b15f929937833c93e85db0835d8903
signature Signed by PGP key B5690EEEBB952194

feat(web): older chat timestamps show the date, not just the time (#6654)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

3 files changed, 91 insertions(+), 3 deletions(-)

apps/web/src/components/chat/MessagesTimeline.tsx+3-3
...@@ -105,7 +105,7 @@ import {...@@ -105,7 +105,7 @@ import {
105import { cn } from "~/lib/utils";105import { cn } from "~/lib/utils";
106import { useUiStateStore } from "~/uiStateStore";106import { useUiStateStore } from "~/uiStateStore";
107import { type TimestampFormat } from "@t3tools/contracts/settings";107import { type TimestampFormat } from "@t3tools/contracts/settings";
108import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";108import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
109109
110import {110import {
111 buildInlineTerminalContextText,111 buildInlineTerminalContextText,
...@@ -1038,7 +1038,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1038,7 +1038,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1038 <div className="flex shrink-0 items-center gap-2">1038 <div className="flex shrink-0 items-center gap-2">
1039 <Tooltip>1039 <Tooltip>
1040 <TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>1040 <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)}
1042 </TooltipTrigger>1042 </TooltipTrigger>
1043 <TooltipPopup>1043 <TooltipPopup>
1044 {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}1044 {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
...@@ -1129,7 +1129,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -1129,7 +1129,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
1129 <TooltipTrigger1129 <TooltipTrigger
1130 render={<p className="text-muted-foreground text-xs tabular-nums" />}1130 render={<p className="text-muted-foreground text-xs tabular-nums" />}
1131 >1131 >
1132 {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)}1132 {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)}
1133 </TooltipTrigger>1133 </TooltipTrigger>
1134 <TooltipPopup>1134 <TooltipPopup>
1135 {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)}1135 {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)}
apps/web/src/timestampFormat.test.ts+50
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
3import {3import {
4 formatDayAwareTimestamp,
4 formatElapsedDurationLabel,5 formatElapsedDurationLabel,
5 formatExpiresInLabel,6 formatExpiresInLabel,
6 formatRelativeTime,7 formatRelativeTime,
...@@ -96,6 +97,55 @@ describe("formatExpiresInLabel", () => {...@@ -96,6 +97,55 @@ describe("formatExpiresInLabel", () => {
96 });97 });
97});98});
9899
100describe("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
99describe("invalid timestamp inputs", () => {149describe("invalid timestamp inputs", () => {
100 it("returns an empty timestamp instead of throwing", () => {150 it("returns an empty timestamp instead of throwing", () => {
101 expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow();151 expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow();
apps/web/src/timestampFormat.ts+38
...@@ -91,6 +91,44 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp...@@ -91,6 +91,44 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp
91 return getTimestampFormatter(timestampFormat, false).format(date);91 return getTimestampFormatter(timestampFormat, false).format(date);
92}92}
9393
94const numericDateFormatter = new Intl.DateTimeFormat(undefined, {
95 month: "numeric",
96 day: "numeric",
97});
98const 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 */
110export 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
94/**132/**
95 * Format a relative time string from an ISO date.133 * Format a relative time string from an ISO date.
96 * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`134 * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`