| author | |
| committer | |
| log | 48ddb3d469d245363dba723f774ba449e4d950c5 |
| tree | 2d8faa3761742be95844f9e56ae47a090f0171a2 |
| parent | 804cba4305b15f929937833c93e85db0835d8903 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 105 | import { cn } from "~/lib/utils"; | 105 | import { cn } from "~/lib/utils"; |
| 106 | import { useUiStateStore } from "~/uiStateStore"; | 106 | import { useUiStateStore } from "~/uiStateStore"; |
| 107 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | 107 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 108 | import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; | 108 | import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; |
| 109 | 109 | ||
| 110 | import { | 110 | import { |
| 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 | <TooltipTrigger | 1129 | <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 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 98 | 99 | ||
| 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 | |||
| 99 | describe("invalid timestamp inputs", () => { | 149 | describe("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 | } |
| 93 | 93 | ||
| 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 | |||
| 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 }` |