From 457d433504b50667b6ba6e1e99b21e3c787bc72a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 14 Aug 2026 18:08:15 -0700 Subject: [PATCH] feat(web): older chat timestamps show the date, not just the time Co-Authored-By: Claude Fable 5 --- .../src/components/chat/MessagesTimeline.tsx | 6 +-- apps/web/src/timestampFormat.test.ts | 50 +++++++++++++++++++ apps/web/src/timestampFormat.ts | 38 ++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e190f47569b2..f9ad57ff3b83 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -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, @@ -1038,7 +1038,7 @@ function UserTimelineRow({ row }: { row: Extract }> - {formatShortTimestamp(row.message.createdAt, ctx.timestampFormat)} + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} @@ -1129,7 +1129,7 @@ function AssistantTimelineRow({ row }: { row: Extract} > - {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} + {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c2fe4b62714f..6678549ccd90 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + formatDayAwareTimestamp, formatElapsedDurationLabel, formatExpiresInLabel, formatRelativeTime, @@ -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(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index cce5b141c634..c8f9956ebb3f 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -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 }`