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 {
105105import { cn } from "~/lib/utils";
106106import { useUiStateStore } from "~/uiStateStore";
107107import { type TimestampFormat } from "@t3tools/contracts/settings";
108import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat";
108import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
109109
110110import {
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+50
......@@ -1,6 +1,7 @@
11import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
33import {
4 formatDayAwareTimestamp,
45 formatElapsedDurationLabel,
56 formatExpiresInLabel,
67 formatRelativeTime,
......@@ -96,6 +97,55 @@ describe("formatExpiresInLabel", () => {
9697 });
9798});
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
99149describe("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+38
......@@ -91,6 +91,44 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp
9191 return getTimestampFormatter(timestampFormat, false).format(date);
9292}
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
94132/**
95133 * Format a relative time string from an ISO date.
96134 * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }`