fix(ui): label every budget reset time in the Admin UI with UTC (#50591)

* fix(ui): label every budget reset time in the Admin UI with UTC

Budget reset times rendered in the browser's timezone with no label, while the proxy resets budgets on UTC boundaries by default. Every budget reset time and budget period now renders in UTC with a UTC label, and both keys tables' Budget Reset column widens to fit it.

* fix(ui): keep the key page's 12-hour clock for the UTC budget reset time

* fix(ui): show clock times in a budget period that does not start at midnight UTC

* fix(ui): show the UTC clock time in the Teams list reset line when it is not midnight

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
zoveki 2026-11-08 08:30:31 -08:00 • committed by GitHub
parent cfddaafe18
commit abee9c945b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 263 additions and 88 deletions

View file

@ -23,7 +23,7 @@ describe("TeamMemberSpendBudgetCell", () => {
expect(screen.getByText("My Spend")).toBeInTheDocument();
expect(screen.getByText("$50.00")).toBeInTheDocument();
expect(screen.getByText("/ $100.00")).toBeInTheDocument();
expect(screen.getByText("Resets Oct 20, 2026 UTC")).toBeInTheDocument();
expect(screen.getByText("Resets Oct 20, 2026 12:00 UTC")).toBeInTheDocument();
expect(screen.getAllByRole("meter")[0]).toHaveAttribute("aria-valuetext", "Team $600.00 of $1,000.00");
expect(screen.getAllByRole("meter")[1]).toHaveAttribute("aria-valuetext", "My Spend $50.00 of $100.00");
});

View file

@ -133,7 +133,7 @@ it("renders caller membership spend and budget in the team row", async () => {
expect(await screen.findByText("My Spend")).toBeInTheDocument();
expect(screen.getByText("$50.00")).toBeInTheDocument();
expect(screen.getByText("Resets Oct 20, 2026 UTC")).toBeInTheDocument();
expect(screen.getByText("Resets Oct 20, 2026 12:00 UTC")).toBeInTheDocument();
});
it("renders the Resources cell with member, model, and key counts", () => {

View file

@ -1,7 +1,7 @@
import { act, screen, waitFor, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { vi, it, expect, beforeEach, describe, Mock, MockedFunction } from "vitest";
import { vi, it, expect, beforeEach, afterEach, describe, Mock, MockedFunction } from "vitest";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import { VirtualKeysTable } from "./VirtualKeysTable";
import { KEY_TABLE_HIDDEN_COLUMNS, KEY_TABLE_SORT_FIELDS } from "./keyTableColumns";
@ -585,6 +585,27 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user
});
});
describe("Budget Reset column", () => {
const originalTimezone: string | undefined = process.env.TZ;
afterEach(() => {
if (originalTimezone === undefined) {
delete process.env.TZ;
return;
}
process.env.TZ = originalTimezone;
});
it("shows the reset time in UTC with a UTC label for a viewer in another timezone", async () => {
process.env.TZ = "America/Los_Angeles";
mockUseKeys.mockReturnValue(keysResult([{ ...mockKey, budget_reset_at: "2026-11-01T00:00:00Z" }]));
renderWithProviders(<VirtualKeysTable />);
expect(await screen.findByText("Nov 1, 00:00:00 UTC")).toBeInTheDocument();
});
});
describe("entity links out of the key rows", () => {
const keyRow = async () => (await screen.findByText("Test Key Alias")).closest("tr") as HTMLElement;

View file

@ -389,11 +389,15 @@ export const getKeyTableColumns = (
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("apiKeys.keyTableColumns.columns.budgetReset")} />
),
size: 130,
size: 160,
enableSorting: true,
sortDescFirst: false,
cell: (info) => (
<DateCell value={info.getValue() as string | null} fallback={t("apiKeys.keyTableColumns.cells.never")} />
<DateCell
value={info.getValue() as string | null}
fallback={t("apiKeys.keyTableColumns.cells.never")}
timeZone="UTC"
/>
),
},
{

View file

@ -22,8 +22,8 @@ const noBudget = { spend: null, maxBudget: null, budgetDuration: "30d", budgetRe
const noBudgetNoDuration = { spend: 2, maxBudget: null, budgetDuration: null, budgetResetAt: null };
describe("budgetPeriodLines", () => {
it("describes the current period, max budget, interval and spend when a reset is scheduled", () => {
process.env.TZ = "UTC";
it("describes the current period in UTC, max budget, interval and spend when a reset is scheduled", () => {
process.env.TZ = "America/Los_Angeles";
expect(budgetPeriodLines(scheduled)).toEqual([
"Budget period: Mar 1, 2026 00:00 to Apr 1, 2026 00:00 (UTC)",
"Max budget: $100.00",
@ -32,8 +32,8 @@ describe("budgetPeriodLines", () => {
]);
});
it("falls back to the next reset when the duration cannot be parsed", () => {
process.env.TZ = "UTC";
it("falls back to the next reset in UTC when the duration cannot be parsed", () => {
process.env.TZ = "Asia/Tokyo";
expect(budgetPeriodLines(unparseableDuration)).toEqual([
"Next reset: Apr 1, 2026 00:00 (UTC)",
"Max budget: $100.00",

View file

@ -1,6 +1,7 @@
"use client";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { CircleHelp } from "lucide-react";
import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown";
@ -16,20 +17,21 @@ export interface BudgetPeriodTooltipProps {
budgetResetAt: string | null | undefined;
}
const formatPeriodDate = (iso: string): string => dayjs(iso).format("MMM D, YYYY HH:mm");
dayjs.extend(utc);
const formatPeriodDate = (iso: string): string => dayjs.utc(iso).format("MMM D, YYYY HH:mm");
const describePeriod = (budgetDuration: string, budgetResetAt: string | null | undefined): string | null => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const period = budgetPeriodRange(budgetDuration, budgetResetAt);
if (period) {
return i18n.t("shared.budgetPeriodTooltip.budgetPeriod", {
start: formatPeriodDate(period.start),
end: formatPeriodDate(period.end),
timeZone,
timeZone: "UTC",
});
}
if (budgetResetAt && dayjs(budgetResetAt).isValid()) {
return i18n.t("shared.budgetPeriodTooltip.nextReset", { date: formatPeriodDate(budgetResetAt), timeZone });
if (budgetResetAt && dayjs.utc(budgetResetAt).isValid()) {
return i18n.t("shared.budgetPeriodTooltip.nextReset", { date: formatPeriodDate(budgetResetAt), timeZone: "UTC" });
}
return null;
};

View file

@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { DateCell, formatCellDate, formatFullTimestamp } from "./date_cell";
@ -55,3 +56,35 @@ describe("DateCell", () => {
expect(screen.queryByText(/Invalid/)).not.toBeInTheDocument();
});
});
describe("DateCell with timeZone UTC", () => {
const originalTimezone: string | undefined = process.env.TZ;
afterEach(() => {
if (originalTimezone === undefined) {
delete process.env.TZ;
return;
}
process.env.TZ = originalTimezone;
});
it("prints the UTC clock time and labels it UTC for a viewer in another zone", async () => {
process.env.TZ = "America/Los_Angeles";
const user = userEvent.setup();
render(<DateCell value="2026-11-01T00:00:00Z" timeZone="UTC" />);
const cell = screen.getByText("Nov 1, 00:00:00 UTC");
await user.hover(cell);
expect(await screen.findByText("Nov 1, 2026, 00:00:00 (UTC)")).toBeInTheDocument();
});
it("labels date precision with UTC too", () => {
process.env.TZ = "Asia/Tokyo";
expect(formatCellDate(new Date("2026-10-31T20:00:00Z"), "date", "UTC")).toBe("Oct 31, 2026 UTC");
});
it("keeps the local default unlabeled for every other column", () => {
process.env.TZ = "America/Los_Angeles";
expect(formatCellDate(new Date("2026-11-01T00:00:00Z"), "datetime")).toBe("Oct 31, 17:00:00");
});
});

View file

@ -23,27 +23,65 @@ const MONTHS: readonly ParseKeys[] = [
export type DatePrecision = "datetime" | "date";
export type DateCellTimeZone = "local" | "UTC";
interface DateCellProps {
value: string | null | undefined;
precision?: DatePrecision;
fallback?: string;
timeZone?: DateCellTimeZone;
}
interface DateParts {
year: number;
month: number;
day: number;
hours: number;
minutes: number;
seconds: number;
}
const pad = (n: number): string => String(n).padStart(2, "0");
export const formatCellDate = (date: Date, precision: DatePrecision): string =>
precision === "date"
? `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${date.getFullYear()}`
: `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
const dateParts = (date: Date, timeZone: DateCellTimeZone): DateParts =>
timeZone === "UTC"
? {
year: date.getUTCFullYear(),
month: date.getUTCMonth(),
day: date.getUTCDate(),
hours: date.getUTCHours(),
minutes: date.getUTCMinutes(),
seconds: date.getUTCSeconds(),
}
: {
year: date.getFullYear(),
month: date.getMonth(),
day: date.getDate(),
hours: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds(),
};
export const formatFullTimestamp = (date: Date): string => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const day = `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${date.getFullYear()}`;
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
return `${day}, ${time} (${timeZone})`;
const clockTime = ({ hours, minutes, seconds }: DateParts): string => `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
const monthName = ({ month }: DateParts): string => i18n.t(MONTHS[month]);
export const formatCellDate = (date: Date, precision: DatePrecision, timeZone: DateCellTimeZone = "local"): string => {
const parts = dateParts(date, timeZone);
const formatted =
precision === "date"
? `${monthName(parts)} ${parts.day}, ${parts.year}`
: `${monthName(parts)} ${parts.day}, ${clockTime(parts)}`;
return timeZone === "UTC" ? `${formatted} UTC` : formatted;
};
export function DateCell({ value, precision = "datetime", fallback = "-" }: DateCellProps) {
export const formatFullTimestamp = (date: Date, timeZone: DateCellTimeZone = "local"): string => {
const parts = dateParts(date, timeZone);
const zoneName = timeZone === "UTC" ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone;
return `${monthName(parts)} ${parts.day}, ${parts.year}, ${clockTime(parts)} (${zoneName})`;
};
export function DateCell({ value, precision = "datetime", fallback = "-", timeZone = "local" }: DateCellProps) {
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) {
return <span className="text-muted-foreground">{fallback}</span>;
@ -51,8 +89,8 @@ export function DateCell({ value, precision = "datetime", fallback = "-" }: Date
return (
<CellTooltip
content={formatFullTimestamp(date)}
trigger={<span className="whitespace-nowrap">{formatCellDate(date, precision)}</span>}
content={formatFullTimestamp(date, timeZone)}
trigger={<span className="whitespace-nowrap">{formatCellDate(date, precision, timeZone)}</span>}
/>
);
}

View file

@ -60,7 +60,7 @@ describe("MyUserTab", () => {
litellm_budget_table: {
max_budget: 100,
budget_duration: "30d",
budget_reset_at: new Date(2026, 6, 15, 12, 0, 0).toISOString(),
budget_reset_at: "2026-07-15T12:00:00.000Z",
},
},
isLoading: false,
@ -70,7 +70,9 @@ describe("MyUserTab", () => {
renderWithProviders(<MyUserTab teamId="team-1" />);
expect(screen.getByText("Current Period Spend (USD)")).toBeInTheDocument();
expect(screen.getByText("Jun 15 - Jul 15, 2026 (resets Jul 15, 2026 12:00)")).toBeInTheDocument();
expect(
screen.getByText("Jun 15, 2026 12:00 - Jul 15, 2026 12:00 UTC (resets Jul 15, 2026 12:00 UTC)"),
).toBeInTheDocument();
expect(screen.queryByText(/cycle/i)).not.toBeInTheDocument();
});
});

View file

@ -32,7 +32,7 @@ const mockSetIsAddMemberModalVisible = vi.fn();
const mockOnMemberSpendReset = vi.fn();
const mockOnMemberBudgetReset = vi.fn();
const budgetResetIso = new Date(2026, 6, 15, 12, 0, 0).toISOString();
const budgetResetIso = "2026-07-15T12:00:00.000Z";
const createMockTeamData = (overrides: Partial<TeamData> = {}): TeamData => ({
team_id: "team-123",
@ -313,7 +313,7 @@ describe("TeamMembersComponent", () => {
/>,
);
expect(screen.getByText("Jul 15, 2026 12:00")).toBeInTheDocument();
expect(screen.getByText("Jul 15, 2026 12:00 UTC")).toBeInTheDocument();
});
it("shows the budget period date range instead of a bare reset date when the duration is known", () => {
@ -334,8 +334,8 @@ describe("TeamMembersComponent", () => {
expect(screen.getByRole("columnheader", { name: /^Budget Period/ })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /^Current Period Spend \(USD\)/ })).toBeInTheDocument();
expect(screen.getByText("Jun 15 - Jul 15, 2026")).toBeInTheDocument();
expect(screen.queryByText("Jul 15, 2026 12:00")).not.toBeInTheDocument();
expect(screen.getByText("Jun 15, 2026 12:00 - Jul 15, 2026 12:00 UTC")).toBeInTheDocument();
expect(screen.queryByText("Jul 15, 2026 12:00 UTC")).not.toBeInTheDocument();
expect(screen.queryByText(/cycle/i)).not.toBeInTheDocument();
});
@ -355,7 +355,7 @@ describe("TeamMembersComponent", () => {
/>,
);
expect(screen.queryByText("Jul 15, 2026 12:00")).not.toBeInTheDocument();
expect(screen.queryByText("Jul 15, 2026 12:00 UTC")).not.toBeInTheDocument();
expect(screen.getAllByText("Never resets").length).toBeGreaterThan(0);
});

View file

@ -1,5 +1,5 @@
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi, MockedFunction } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor, within } from "../../../tests/test-utils";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
@ -184,6 +184,33 @@ describe("TeamVirtualKeysTable", () => {
expect(screen.getByText("bob_key_team1")).toBeInTheDocument();
});
describe("Budget Reset column", () => {
const originalTimezone: string | undefined = process.env.TZ;
afterEach(() => {
if (originalTimezone === undefined) {
delete process.env.TZ;
return;
}
process.env.TZ = originalTimezone;
});
it("shows the reset time in UTC with a UTC label for a viewer in another timezone", async () => {
process.env.TZ = "America/Los_Angeles";
const keysResponse: KeysResponse = {
keys: [createMockKey({ budget_reset_at: "2026-11-01T00:00:00Z" })],
total_count: 1,
current_page: 1,
total_pages: 1,
};
mockUseKeys.mockReturnValue(createKeysQueryResult(keysResponse));
renderWithProviders(<TeamVirtualKeysTable {...defaultProps} />);
expect(await screen.findByText("Nov 1, 00:00:00 UTC")).toBeInTheDocument();
});
});
it("shows the team member budget for budgetless keys and keeps each key's own budget", async () => {
const keysResponse: KeysResponse = {
keys: [

View file

@ -406,11 +406,15 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
header: ({ column }) => (
<DataTableSortHeader column={column} title={t("teams.virtualKeysTable.columns.budgetReset")} />
),
size: 130,
size: 160,
enableSorting: true,
sortDescFirst: false,
cell: (info) => (
<DateCell value={info.getValue() as string | null} fallback={t("teams.virtualKeysTable.dates.never")} />
<DateCell
value={info.getValue() as string | null}
fallback={t("teams.virtualKeysTable.dates.never")}
timeZone="UTC"
/>
),
},
{

View file

@ -517,7 +517,7 @@ describe("KeyInfoView handleKeyUpdate budget_duration", () => {
);
fireEvent.click(screen.getByText("Settings"));
expect(screen.getByTestId("budget-reset-value")).toHaveTextContent("Every 30d");
expect(screen.getByTestId("budget-reset-value")).toHaveTextContent("Every 30d, next Sep 1, 2026 at 12:00 AM UTC");
fireEvent.click(screen.getByText("Edit Settings"));
(globalThis as any).__TEST_FORM_VALUES = {

View file

@ -424,7 +424,7 @@ describe("KeyInfoView budget reset visibility", () => {
/>,
);
await waitFor(() => {
expect(screen.getByText(/^Resets Jul 22, 2026/)).toBeInTheDocument();
expect(screen.getByText("Resets Jul 22, 2026 at 12:00 PM UTC")).toBeInTheDocument();
});
});

View file

@ -5,6 +5,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"
import { useApplyUserBudgetToTeamKeys } from "@/app/(dashboard)/hooks/uiSettings/useApplyUserBudgetToTeamKeys";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { formatBudgetResetTime } from "@/utils/budgetUtils";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import { ArrowLeft, Info } from "lucide-react";
@ -579,6 +580,7 @@ export default function KeyInfoView({
return t("keyDetail.keyInfoView.budget.unlimited");
})();
const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg, ownerUser, memberGate);
const budgetResetTime = formatBudgetResetTime(currentKeyData.budget_reset_at);
return (
<div className="w-full h-full overflow-y-auto p-4">
@ -782,10 +784,10 @@ export default function KeyInfoView({
: t("keyDetail.keyInfoView.overview.ofBudget", { budget: budgetDisplay })}
<InheritedBudgetHint gates={inheritedGates} />
</p>
{currentKeyData.budget_reset_at && (
{budgetResetTime && (
<p className="text-sm">
{t("keyDetail.keyInfoView.overview.resets")}
{formatTimestamp(currentKeyData.budget_reset_at)}
{budgetResetTime}
</p>
)}
<p className="text-sm mt-2" data-testid="key-lifetime-spend">
@ -1111,8 +1113,8 @@ export default function KeyInfoView({
<div>
<p className="text-sm font-medium">{t("keyDetail.keyInfoView.settings.budgetReset")}</p>
<p data-testid="budget-reset-value" className="text-sm">
{currentKeyData.budget_reset_at
? `${currentKeyData.budget_duration ? t("keyDetail.keyInfoView.settings.budgetResetEvery", { duration: currentKeyData.budget_duration }) : ""}${formatTimestamp(currentKeyData.budget_reset_at)}`
{budgetResetTime
? `${currentKeyData.budget_duration ? t("keyDetail.keyInfoView.settings.budgetResetEvery", { duration: currentKeyData.budget_duration }) : ""}${budgetResetTime}`
: t("keyDetail.keyInfoView.settings.never")}
</p>
</div>

View file

@ -1,9 +1,14 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { budgetPeriodRange, describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils";
import {
budgetPeriodRange,
describeBudgetResetSchedule,
formatBudgetResetTime,
formatBudgetResetUtc,
} from "./budgetUtils";
const localIso = (year: number, monthIndex: number, day: number, hour = 12): string =>
new Date(year, monthIndex, day, hour, 0, 0).toISOString();
const utcIso = (year: number, monthIndex: number, day: number, hour = 12): string =>
new Date(Date.UTC(year, monthIndex, day, hour, 0, 0)).toISOString();
const originalTimezone: string | undefined = process.env.TZ;
@ -18,14 +23,36 @@ afterEach(() => {
describe("formatBudgetResetUtc", () => {
it("formats the reset date in UTC regardless of the local timezone", () => {
process.env.TZ = "America/Los_Angeles";
const resetAt: string = "2026-10-20T00:00:00Z";
expect(formatBudgetResetUtc("2026-10-20T00:00:00Z")).toBe("Oct 20, 2026");
});
expect(formatBudgetResetUtc(resetAt)).toBe("Oct 20, 2026");
expect(formatBudgetReset(resetAt)).toBe("Oct 19, 2026");
it("adds the UTC clock time when the reset is not at midnight UTC", () => {
process.env.TZ = "America/Los_Angeles";
expect(formatBudgetResetUtc("2026-11-30T18:30:00Z")).toBe("Nov 30, 2026 18:30");
});
});
describe("formatBudgetResetTime", () => {
it.each(["America/Los_Angeles", "Asia/Tokyo", "UTC"])(
"prints the reset instant in UTC and says so for a %s viewer",
(timeZone) => {
process.env.TZ = timeZone;
expect(formatBudgetResetTime("2026-11-01T00:00:00Z")).toBe("Nov 1, 2026 at 12:00 AM UTC");
expect(formatBudgetResetTime("2026-10-31T17:30:00-07:00")).toBe("Nov 1, 2026 at 12:30 AM UTC");
expect(formatBudgetResetTime("2026-11-01T13:05:00Z")).toBe("Nov 1, 2026 at 1:05 PM UTC");
},
);
it.each([null, undefined, "", "not-a-date"])("returns null for %s", (value) => {
expect(formatBudgetResetTime(value)).toBeNull();
});
});
describe("describeBudgetResetSchedule", () => {
beforeEach(() => {
process.env.TZ = "America/Los_Angeles";
});
it("reports lifetime spend with no period when the budget has no duration and no scheduled reset", () => {
const schedule = describeBudgetResetSchedule(null, null);
expect(schedule.label).toBe("Never resets");
@ -41,62 +68,67 @@ describe("describeBudgetResetSchedule", () => {
expect(schedule.tooltip).toContain("every 30d");
});
it("shows the next reset with its time zone and the rollover carryover note", () => {
const resetAt = new Date(2026, 6, 15, 12, 0, 0);
const schedule = describeBudgetResetSchedule("30d", resetAt.toISOString());
expect(schedule.label).toBe("Jul 15, 2026 12:00");
expect(schedule.resetAt).toBe(resetAt.toISOString());
expect(schedule.tooltip).toContain(Intl.DateTimeFormat().resolvedOptions().timeZone);
it("shows the next reset in UTC with the rollover carryover note", () => {
const resetAt = utcIso(2026, 6, 15);
const schedule = describeBudgetResetSchedule("30d", resetAt);
expect(schedule.label).toBe("Jul 15, 2026 12:00 UTC");
expect(schedule.resetAt).toBe(resetAt);
expect(schedule.tooltip).toContain("Next reset Jul 15, 2026 12:00 (UTC).");
expect(schedule.tooltip).toContain("carries into the next period");
});
it("treats 30d as one calendar month back from the reset, like the proxy does", () => {
const schedule = describeBudgetResetSchedule("30d", localIso(2026, 10, 1, 0));
expect(schedule.period).toBe("Oct 1 - Nov 1, 2026");
expect(schedule.tooltip).toContain("covers Oct 1 - Nov 1, 2026 and resets every 30d");
const schedule = describeBudgetResetSchedule("30d", utcIso(2026, 10, 1, 0));
expect(schedule.period).toBe("Oct 1 - Nov 1, 2026 UTC");
expect(schedule.tooltip).toContain("covers Oct 1 - Nov 1, 2026 UTC and resets every 30d");
});
it("subtracts the exact duration for day and week windows", () => {
expect(describeBudgetResetSchedule("7d", localIso(2026, 6, 15)).period).toBe("Jul 8 - Jul 15, 2026");
expect(describeBudgetResetSchedule("2w", localIso(2026, 6, 15)).period).toBe("Jul 1 - Jul 15, 2026");
expect(describeBudgetResetSchedule("weekly", localIso(2026, 6, 15)).period).toBe("Jul 8 - Jul 15, 2026");
expect(describeBudgetResetSchedule("7d", utcIso(2026, 6, 15, 0)).period).toBe("Jul 8 - Jul 15, 2026 UTC");
expect(describeBudgetResetSchedule("2w", utcIso(2026, 6, 15, 0)).period).toBe("Jul 1 - Jul 15, 2026 UTC");
expect(describeBudgetResetSchedule("weekly", utcIso(2026, 6, 15, 0)).period).toBe("Jul 8 - Jul 15, 2026 UTC");
});
it("walks the month back in UTC so a non-UTC viewer sees the proxy's window", () => {
const originalTz = process.env.TZ;
it("shows the proxy's UTC month window to a viewer whose local date is a day behind", () => {
process.env.TZ = "America/New_York";
try {
const schedule = describeBudgetResetSchedule("30d", "2026-03-01T00:00:00Z");
expect(schedule.period).toBe("Jan 31 - Feb 28, 2026");
} finally {
process.env.TZ = originalTz;
}
const schedule = describeBudgetResetSchedule("30d", "2026-03-01T00:00:00Z");
expect(schedule.period).toBe("Feb 1 - Mar 1, 2026 UTC");
expect(schedule.label).toBe("Mar 1, 2026 00:00 UTC");
});
it("clamps month windows to the last day of a shorter month", () => {
expect(describeBudgetResetSchedule("1mo", localIso(2026, 2, 31)).period).toBe("Feb 28 - Mar 31, 2026");
expect(describeBudgetResetSchedule("1mo", utcIso(2026, 2, 31, 0)).period).toBe("Feb 28 - Mar 31, 2026 UTC");
});
it("spells out both years when the period crosses a year boundary", () => {
expect(describeBudgetResetSchedule("30d", localIso(2027, 0, 10)).period).toBe("Dec 10, 2026 - Jan 10, 2027");
expect(describeBudgetResetSchedule("30d", utcIso(2027, 0, 10, 0)).period).toBe("Dec 10, 2026 - Jan 10, 2027 UTC");
});
it("keeps the clock times when the reset is not at midnight UTC", () => {
expect(describeBudgetResetSchedule("1d", "2026-11-08T18:30:00Z").period).toBe(
"Nov 7, 2026 18:30 - Nov 8, 2026 18:30 UTC",
);
expect(describeBudgetResetSchedule("30d", "2026-12-01T09:00:00Z").period).toBe(
"Nov 1, 2026 09:00 - Dec 1, 2026 09:00 UTC",
);
});
it("keeps both clock times when an hourly window crosses midnight", () => {
expect(describeBudgetResetSchedule("1h", localIso(2026, 6, 16, 0)).period).toBe(
"Jul 15, 2026 23:00 - Jul 16, 2026 00:00",
expect(describeBudgetResetSchedule("1h", utcIso(2026, 6, 16, 0)).period).toBe(
"Jul 15, 2026 23:00 - Jul 16, 2026 00:00 UTC",
);
});
it("shows seconds for a window shorter than a minute", () => {
expect(describeBudgetResetSchedule("30s", localIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:59:30 - 12:00:00");
expect(describeBudgetResetSchedule("30s", utcIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:59:30 - 12:00:00 UTC");
});
it("shows clock times for a window that starts and ends on the same day", () => {
expect(describeBudgetResetSchedule("1h", localIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:00 - 12:00");
expect(describeBudgetResetSchedule("1h", utcIso(2026, 6, 15)).period).toBe("Jul 15, 2026 11:00 - 12:00 UTC");
});
it("falls back to a one day window for a duration the proxy would not recognise", () => {
expect(describeBudgetResetSchedule("fortnightly", localIso(2026, 6, 15)).period).toBe("Jul 14 - Jul 15, 2026");
expect(describeBudgetResetSchedule("fortnightly", utcIso(2026, 6, 15, 0)).period).toBe("Jul 14 - Jul 15, 2026 UTC");
});
it("shows no period when the reset date is unparseable", () => {
@ -108,7 +140,7 @@ describe("describeBudgetResetSchedule", () => {
it("never describes the window as a cycle", () => {
const schedules = [
describeBudgetResetSchedule("30d", localIso(2026, 6, 15)),
describeBudgetResetSchedule("30d", utcIso(2026, 6, 15)),
describeBudgetResetSchedule("30d", null),
describeBudgetResetSchedule(null, null),
];

View file

@ -6,18 +6,24 @@ import i18n from "@/i18n/i18n";
dayjs.extend(utc);
export function formatBudgetReset(iso: string | null | undefined): string | null {
export function formatBudgetResetTime(iso: string | null | undefined, t: TFunction = i18n.t.bind(i18n)): string | null {
if (!iso) return null;
const resetDate = dayjs(iso);
const resetDate = dayjs.utc(iso);
if (!resetDate.isValid()) return null;
return resetDate.format("MMM D, YYYY");
const timestamp = t("keyDetail.keyInfoView.timestamp", {
date: resetDate.format("MMM D, YYYY"),
time: resetDate.format("h:mm A"),
});
return `${timestamp} UTC`;
}
const isMidnight = (instant: Dayjs): boolean => instant.isSame(instant.startOf("day"));
export function formatBudgetResetUtc(iso: string | null | undefined): string | null {
if (!iso) return null;
const resetDate = dayjs.utc(iso);
if (!resetDate.isValid()) return null;
return resetDate.format("MMM D, YYYY");
return resetDate.format(isMidnight(resetDate) ? "MMM D, YYYY" : "MMM D, YYYY HH:mm");
}
export interface BudgetResetSchedule {
@ -62,7 +68,7 @@ export function getBudgetPeriodStart(budgetDuration: string, resetAt: Dayjs): Da
export function formatBudgetPeriod(start: Dayjs, end: Dayjs): string {
const windowSeconds = end.diff(start, "second");
if (windowSeconds < 24 * 60 * 60) {
if (windowSeconds < 24 * 60 * 60 || !isMidnight(start) || !isMidnight(end)) {
const time = windowSeconds < 60 ? "HH:mm:ss" : "HH:mm";
const endFormat = start.isSame(end, "day") ? time : `MMM D, YYYY ${time}`;
return `${start.format(`MMM D, YYYY ${time}`)} - ${end.format(endFormat)}`;
@ -76,17 +82,21 @@ export function describeBudgetResetSchedule(
budgetResetAt: string | null | undefined,
t: TFunction = i18n.t.bind(i18n),
): BudgetResetSchedule {
const resetDate = budgetResetAt ? dayjs(budgetResetAt) : null;
const resetDate = budgetResetAt ? dayjs.utc(budgetResetAt) : null;
if (resetDate?.isValid()) {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const label = resetDate.format("MMM D, YYYY HH:mm");
const label = resetDate.format("MMM D, YYYY HH:mm [UTC]");
const period = budgetDuration
? formatBudgetPeriod(getBudgetPeriodStart(budgetDuration, resetDate.utc()).local(), resetDate)
? `${formatBudgetPeriod(getBudgetPeriodStart(budgetDuration, resetDate), resetDate)} UTC`
: null;
const coverage = period
? t("budgetSchedule.coverageWithPeriod", { period, duration: budgetDuration })
: t("budgetSchedule.coverageWithoutPeriod");
const tooltipValues = { coverage, label, timeZone, rolloverNote: t("budgetSchedule.rolloverNote") };
const tooltipValues = {
coverage,
label: resetDate.format("MMM D, YYYY HH:mm"),
timeZone: "UTC",
rolloverNote: t("budgetSchedule.rolloverNote"),
};
return {
label,
resetAt: resetDate.toISOString(),