From 13e98942685f95b6de9a468de8acdb923347e766 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 19 Oct 2026 22:16:20 -0500 Subject: [PATCH 1/3] fix(web): usage-limit banner reset time follows the Time format setting The composer banner printed the reset with toLocaleString(), so it showed a 12-hour clock with seconds even when Time format was 24-hour, while the timeline row above it formats the same resetAt with the setting. Use formatUpcomingTimestamp with the client's timestampFormat, as the timeline row does. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 1 + .../chat/UsageLimitRecoveryBanner.test.tsx | 35 +++++++++++++++++++ .../chat/UsageLimitRecoveryBanner.tsx | 7 ++-- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index da367a88d0f66..75c44830b7da2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4070,6 +4070,7 @@ export default function ChatView(props: ChatViewProps) { stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, recovery: activeThreadShell.limitRecovery ?? null, snoozedUntil: activeThreadShell.snoozedUntil, + timestampFormat, onChange: async (limitRecovery) => { const result = await updateThreadMetadata({ environmentId, diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx new file mode 100644 index 0000000000000..6e5cf395da238 --- /dev/null +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx @@ -0,0 +1,35 @@ +import { RunId } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { formatShortTimestamp } from "../../timestampFormat"; +import { usageLimitRecoveryBannerItem } from "./UsageLimitRecoveryBanner"; + +describe("usageLimitRecoveryBannerItem", () => { + const resetAt = new Date(2026, 9, 20, 17, 30).toISOString(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 9, 20, 12, 0)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each(["12-hour", "24-hour"] as const)( + "formats the reset time with the %s setting", + (timestampFormat) => { + const item = usageLimitRecoveryBannerItem({ + runId: RunId.make("run-1"), + resetAt, + stoppedAt: new Date(2026, 9, 20, 12, 0).toISOString(), + snoozedUntil: null, + recovery: null, + timestampFormat, + onChange: async () => {}, + }); + + expect(item.description).toBe(`Resets ${formatShortTimestamp(resetAt, timestampFormat)}`); + }, + ); +}); diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index dc7ea2de2d57e..70a6ab71f61e9 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -3,9 +3,11 @@ import { type OrchestrationV2LimitRecoveryUpdate, type RunId, } from "@t3tools/contracts"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; import { GaugeIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "../ui/button"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; import { toastManager } from "../ui/toast"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; @@ -15,11 +17,12 @@ type RecoveryProps = { stoppedAt: string; snoozedUntil: string | null; recovery: OrchestrationV2LimitRecovery | null; + timestampFormat: TimestampFormat; onChange: (recovery: OrchestrationV2LimitRecoveryUpdate) => Promise; }; export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBannerStackItem { - const { runId, resetAt, stoppedAt } = props; + const { runId, resetAt, stoppedAt, timestampFormat } = props; const canSchedule = resetAt !== null && Date.parse(resetAt) > Date.parse(stoppedAt); return { id: `usage-limit-recovery:${runId}`, @@ -28,7 +31,7 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann icon: , title: "Usage limit reached", description: resetAt - ? `Resets ${new Date(resetAt).toLocaleString()}` + ? `Resets ${formatUpcomingTimestamp(resetAt, timestampFormat)}` : "Reset time unavailable; retry manually", actions: canSchedule ? : null, }; From e1a9af7cf6ffd14019bb8c75a0341f9b49e9c887 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 19 Oct 2026 22:43:56 -0500 Subject: [PATCH 2/3] test(web): cover the default locale Time format in the banner test The default Time format is "locale", so most users see this banner path. Add it to the reset-time cases; it fails on main the same way the explicit 12-hour and 24-hour cases do. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx index 6e5cf395da238..013d968f5f02f 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx @@ -16,7 +16,7 @@ describe("usageLimitRecoveryBannerItem", () => { vi.useRealTimers(); }); - it.each(["12-hour", "24-hour"] as const)( + it.each(["locale", "12-hour", "24-hour"] as const)( "formats the reset time with the %s setting", (timestampFormat) => { const item = usageLimitRecoveryBannerItem({ From e7e5b1f9d1f7190a0509df40697a6d0b34b9e370 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 06:35:14 -0700 Subject: [PATCH 3/3] test(web): assert literal reset labels in the usage-limit banner test Compare against fixed strings instead of formatShortTimestamp, which mirrored the implementation, and cover a reset that lands tomorrow. Co-Authored-By: Claude Opus 5.5 --- .../chat/UsageLimitRecoveryBanner.test.tsx | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx index 013d968f5f02f..0938e7a3c6889 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx @@ -1,35 +1,36 @@ import { RunId } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { formatShortTimestamp } from "../../timestampFormat"; import { usageLimitRecoveryBannerItem } from "./UsageLimitRecoveryBanner"; -describe("usageLimitRecoveryBannerItem", () => { - const resetAt = new Date(2026, 9, 20, 17, 30).toISOString(); +const stoppedAt = new Date(2026, 9, 20, 12, 0); - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(2026, 9, 20, 12, 0)); - }); +function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) { + return usageLimitRecoveryBannerItem({ + runId: RunId.make("run-1"), + resetAt: resetAt.toISOString(), + stoppedAt: stoppedAt.toISOString(), + snoozedUntil: null, + recovery: null, + timestampFormat, + onChange: async () => {}, + }).description; +} - afterEach(() => { - vi.useRealTimers(); +describe("usageLimitRecoveryBannerItem", () => { + it.each([ + ["12-hour", "Resets 5:30 PM"], + ["24-hour", "Resets 17:30"], + ] as const)("formats a same-day reset with the %s setting", (timestampFormat, expected) => { + using _now = vi.spyOn(Date, "now").mockReturnValue(stoppedAt.getTime()); + expect(bannerDescription(new Date(2026, 9, 20, 17, 30), timestampFormat)).toBe(expected); }); - it.each(["locale", "12-hour", "24-hour"] as const)( - "formats the reset time with the %s setting", - (timestampFormat) => { - const item = usageLimitRecoveryBannerItem({ - runId: RunId.make("run-1"), - resetAt, - stoppedAt: new Date(2026, 9, 20, 12, 0).toISOString(), - snoozedUntil: null, - recovery: null, - timestampFormat, - onChange: async () => {}, - }); - - expect(item.description).toBe(`Resets ${formatShortTimestamp(resetAt, timestampFormat)}`); - }, - ); + it("names the day when the reset is tomorrow", () => { + using _now = vi.spyOn(Date, "now").mockReturnValue(stoppedAt.getTime()); + expect(bannerDescription(new Date(2026, 9, 21, 17, 30), "24-hour")).toBe( + "Resets tomorrow at 17:30", + ); + }); });