fix(ui): show the budget period date range instead of "cycle" on team member spend (#48488)

* fix(ui): show the budget period date range instead of "cycle" on team member spend

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): derive the budget period start in UTC before showing it in local time

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): keep clock times on sub-day budget periods and restore the total spend lower bound wording

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: joshua <joshua@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-26 17:24:44 -07:00 • committed by GitHub
parent 549e6d4f46
commit 100529391a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 198 additions and 35 deletions

View file

@ -48,7 +48,7 @@ describe("MyUserTab", () => {
expect(screen.getByText("Never resets")).toBeInTheDocument();
});
it("shows the next reset time when the member budget has a reset window", () => {
it("shows the budget period and next reset when the member budget has a reset window", () => {
vi.mocked(useMyTeamMember).mockReturnValue({
data: {
user_id: "user-1",
@ -69,6 +69,8 @@ describe("MyUserTab", () => {
renderWithProviders(<MyUserTab teamId="team-1" />);
expect(screen.getByText("Resets Jul 15, 2026 12:00")).toBeInTheDocument();
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.queryByText(/cycle/i)).not.toBeInTheDocument();
});
});

View file

@ -1,4 +1,4 @@
import { describeBudgetResetSchedule } from "@/utils/budgetUtils";
import { BudgetResetSchedule, describeBudgetResetSchedule } from "@/utils/budgetUtils";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
@ -30,6 +30,12 @@ const formatRateLimit = (value: number | null | undefined): string => {
return formatNumberWithCommas(value, 0);
};
function describePeriodLine(schedule: BudgetResetSchedule): string {
if (schedule.period) return `${schedule.period} (resets ${schedule.label})`;
if (schedule.resetAt) return `Resets ${schedule.label}`;
return schedule.label;
}
export default function MyUserTab({ teamId }: MyUserTabProps) {
const { data, isLoading, error } = useMyTeamMember(teamId);
@ -93,16 +99,14 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardContent>
{labelWithTooltip("Current Cycle Spend (USD)", budgetReset.tooltip)}
{labelWithTooltip("Current Period Spend (USD)", budgetReset.tooltip)}
<div className="mt-2">
<h3 className="text-2xl font-semibold">${formatNumber(spend, 4)}</h3>
<span className="text-muted-foreground">
of {maxBudget === null ? "Unlimited" : `$${formatNumber(maxBudget, 4)}`}
</span>
</div>
<div className="mt-1 text-muted-foreground">
{budgetReset.resetAt ? `Resets ${budgetReset.label}` : budgetReset.label}
</div>
<div className="mt-1 text-muted-foreground">{describePeriodLine(budgetReset)}</div>
</CardContent>
</Card>
@ -121,7 +125,7 @@ export default function MyUserTab({ teamId }: MyUserTabProps) {
<CardContent>
{labelWithTooltip(
"Total Spend (USD)",
"Cumulative spend across budget cycles within this team. This is a lower bound: lifetime tracking began 2026-04-21, so cycles completed before then are not included. It is never less than Current Cycle Spend.",
"Cumulative spend within this team across budget periods. This is a lower bound: lifetime tracking began 2026-04-21, so periods that reset before then are not included. It is never less than Current Period Spend.",
)}
<h4 className="mt-2 text-xl font-semibold">${formatNumber(totalSpend, 4)}</h4>
</CardContent>

View file

@ -316,6 +316,29 @@ describe("TeamMembersComponent", () => {
expect(screen.getByText("Jul 15, 2026 12:00")).toBeInTheDocument();
});
it("shows the budget period date range instead of a bare reset date when the duration is known", () => {
const teamData = createMockTeamData();
teamData.team_memberships[0].litellm_budget_table!.budget_duration = "30d";
renderWithProviders(
<TeamMembersComponent
teamData={teamData}
canEditTeam={false}
handleMemberDelete={mockHandleMemberDelete}
onMemberSpendReset={mockOnMemberSpendReset}
onMemberBudgetReset={mockOnMemberBudgetReset}
setSelectedEditMember={mockSetSelectedEditMember}
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
/>,
);
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.queryByText(/cycle/i)).not.toBeInTheDocument();
});
it("shows Never resets for a member whose budget has no reset window", () => {
const teamData = createMockTeamData();
teamData.team_memberships[0].litellm_budget_table!.budget_reset_at = null;
@ -696,7 +719,7 @@ describe("TeamMembersComponent", () => {
/>,
);
it("resets the member's current cycle spend to $0 after confirming, then refreshes the team", async () => {
it("resets the member's current period spend to $0 after confirming, then refreshes the team", async () => {
const user = userEvent.setup();
POST.mockResolvedValue({ data: {} });
renderEditableTab();
@ -745,7 +768,7 @@ describe("TeamMembersComponent", () => {
expect(POST).not.toHaveBeenCalled();
});
it("disables the reset with a reason on members that have no current cycle spend", async () => {
it("disables the reset with a reason on members that have no current period spend", async () => {
const user = userEvent.setup();
renderEditableTab();
@ -753,7 +776,7 @@ describe("TeamMembersComponent", () => {
"reset-member-spend",
);
await user.hover(resetButton);
expect(await screen.findByText("No current cycle spend to reset")).toBeInTheDocument();
expect(await screen.findByText("No current period spend to reset")).toBeInTheDocument();
await user.click(resetButton);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();

View file

@ -102,7 +102,7 @@ export default function TeamMemberTab({
return "0";
};
const getUserCurrentCycleSpend = (userId: string | null): number => {
const getUserCurrentPeriodSpend = (userId: string | null): number => {
if (!userId) return 0;
return findMembership(userId)?.spend ?? 0;
};
@ -153,12 +153,16 @@ export default function TeamMemberTab({
return findMembership(userId)?.litellm_budget_table?.budget_reset_at ?? null;
};
const renderBudgetReset = (userId: string | null) => {
const getUserBudgetSchedule = (userId: string | null) => {
const budget = findMembership(userId)?.litellm_budget_table;
const schedule = describeBudgetResetSchedule(budget?.budget_duration ?? null, budget?.budget_reset_at ?? null);
return describeBudgetResetSchedule(budget?.budget_duration ?? null, budget?.budget_reset_at ?? null);
};
const renderBudgetPeriod = (userId: string | null) => {
const schedule = getUserBudgetSchedule(userId);
return (
<SimpleTooltip content={schedule.tooltip}>
<span>{schedule.label}</span>
<span>{schedule.period ?? schedule.label}</span>
</SimpleTooltip>
);
};
@ -200,22 +204,22 @@ export default function TeamMemberTab({
{
title: (
<span className="flex items-center gap-1">
Current Cycle Spend (USD)
<SimpleTooltip content="Spend in this member's current budget cycle, the value checked against their budget. The Budget Reset column shows when this cycle ends, or Never resets. Spend above a rollover cap at reset carries into the next cycle.">
<CircleHelp className="size-4" aria-label="Current cycle spend information" />
Current Period Spend (USD)
<SimpleTooltip content="Spend in this member's current budget period, the value checked against their budget. The Budget Period column shows the dates that period covers, or Never resets. Spend above a rollover cap at reset carries into the next period.">
<CircleHelp className="size-4" aria-label="Current period spend information" />
</SimpleTooltip>
</span>
),
key: "spend",
numeric: true,
sortValue: (record: Member) => getUserCurrentCycleSpend(record.user_id),
render: (record: Member) => <MoneyCell value={getUserCurrentCycleSpend(record.user_id)} decimals={2} />,
sortValue: (record: Member) => getUserCurrentPeriodSpend(record.user_id),
render: (record: Member) => <MoneyCell value={getUserCurrentPeriodSpend(record.user_id)} decimals={2} />,
},
{
title: (
<span className="flex items-center gap-1">
Total Spend (USD)
<SimpleTooltip content="Cumulative spend by this member within this team, across budget cycles. This is a lower bound: lifetime tracking began 2026-04-21, so cycles completed before then are not included. It is never less than Current Cycle Spend.">
<SimpleTooltip content="Cumulative spend by this member within this team, across budget periods. This is a lower bound: lifetime tracking began 2026-04-21, so periods that reset before then are not included. It is never less than Current Period Spend.">
<CircleHelp className="size-4" aria-label="Total spend information" />
</SimpleTooltip>
</span>
@ -263,10 +267,10 @@ export default function TeamMemberTab({
},
},
{
title: "Budget Reset",
title: "Budget Period",
key: "budget_reset",
sortValue: (record: Member) => getUserBudgetReset(record.user_id),
render: (record: Member) => renderBudgetReset(record.user_id),
render: (record: Member) => renderBudgetPeriod(record.user_id),
},
{
title: (
@ -314,10 +318,12 @@ export default function TeamMemberTab({
const resetSpendDisabledReason = (member: Member): string | null => {
if (!isProxyAdmin && member.user_id === userId) return "Ask a proxy admin to reset your own spend";
if (getUserCurrentCycleSpend(member.user_id) <= 0) return "No current cycle spend to reset";
if (getUserCurrentPeriodSpend(member.user_id) <= 0) return "No current period spend to reset";
return null;
};
const resetSpendPeriod = getUserBudgetSchedule(memberToResetSpend?.user_id ?? null).period;
return (
<>
<MemberTable
@ -352,13 +358,16 @@ export default function TeamMemberTab({
<DialogTitle>Reset Team Member Spend</DialogTitle>
</DialogHeader>
<p>
Reset current cycle spend for{" "}
Reset current period spend for{" "}
<strong>{memberToResetSpend?.user_email || memberToResetSpend?.user_id}</strong> in this team to{" "}
<strong>$0</strong>?
</p>
<p className="text-sm text-muted-foreground">
Current cycle spend:{" "}
<strong>${formatNumberWithCommas(getUserCurrentCycleSpend(memberToResetSpend?.user_id ?? null), 4)}</strong>
Current period spend
{resetSpendPeriod ? ` (${resetSpendPeriod})` : ""}:{" "}
<strong>
${formatNumberWithCommas(getUserCurrentPeriodSpend(memberToResetSpend?.user_id ?? null), 4)}
</strong>
. This is the value checked against the member&apos;s budget. Total spend and logs are preserved.
</p>
<DialogFooter>

View file

@ -2,6 +2,9 @@ import { afterEach, describe, expect, it } from "vitest";
import { budgetPeriodRange, describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils";
const localIso = (year: number, monthIndex: number, day: number, hour = 12): string =>
new Date(year, monthIndex, day, hour, 0, 0).toISOString();
const originalTimezone: string | undefined = process.env.TZ;
afterEach(() => {
@ -23,15 +26,18 @@ describe("formatBudgetResetUtc", () => {
});
describe("describeBudgetResetSchedule", () => {
it("reports Never resets when the budget has no duration and no scheduled reset", () => {
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");
expect(schedule.resetAt).toBeNull();
expect(schedule.period).toBeNull();
expect(schedule.tooltip).toContain("lifetime spend");
});
it("reports Unknown when a duration is set but no reset has been scheduled yet", () => {
const schedule = describeBudgetResetSchedule("30d", null);
expect(schedule.label).toBe("Unknown");
expect(schedule.period).toBeNull();
expect(schedule.tooltip).toContain("every 30d");
});
@ -41,7 +47,74 @@ describe("describeBudgetResetSchedule", () => {
expect(schedule.label).toBe("Jul 15, 2026 12:00");
expect(schedule.resetAt).toBe(resetAt.toISOString());
expect(schedule.tooltip).toContain(Intl.DateTimeFormat().resolvedOptions().timeZone);
expect(schedule.tooltip).toContain("carries into the next cycle");
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");
});
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");
});
it("walks the month back in UTC so a non-UTC viewer sees the proxy's window", () => {
const originalTz = process.env.TZ;
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;
}
});
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");
});
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");
});
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",
);
});
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");
});
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");
});
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");
});
it("shows no period when the reset date is unparseable", () => {
const schedule = describeBudgetResetSchedule("30d", "not-a-date");
expect(schedule.label).toBe("Unknown");
expect(schedule.period).toBeNull();
expect(schedule.tooltip).not.toMatch(/NaN|Invalid/);
});
it("never describes the window as a cycle", () => {
const schedules = [
describeBudgetResetSchedule("30d", localIso(2026, 6, 15)),
describeBudgetResetSchedule("30d", null),
describeBudgetResetSchedule(null, null),
];
for (const schedule of schedules) {
expect(`${schedule.label} ${schedule.tooltip}`).not.toMatch(/cycle/i);
}
});
});

View file

@ -1,4 +1,4 @@
import dayjs from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
@ -20,10 +20,55 @@ export function formatBudgetResetUtc(iso: string | null | undefined): string | n
export interface BudgetResetSchedule {
label: string;
resetAt: string | null;
period: string | null;
tooltip: string;
}
const ROLLOVER_NOTE = "Spend above a rollover cap at reset carries into the next cycle.";
const ROLLOVER_NOTE = "Spend above a rollover cap at reset carries into the next period.";
const DURATION_WORD_ALIASES: Record<string, string> = {
hourly: "1h",
daily: "24h",
weekly: "7d",
monthly: "30d",
};
const DURATION_PATTERN = /^(\d+)(mo|[smhdw])$/;
// Mirrors the proxy's get_budget_window_start: "30d" and "Nmo" span calendar months,
// everything else is a fixed length, and an unrecognised duration resets at the next midnight.
export function getBudgetPeriodStart(budgetDuration: string, resetAt: Dayjs): Dayjs {
const trimmed = budgetDuration.trim();
const normalized = DURATION_WORD_ALIASES[trimmed.toLowerCase()] ?? trimmed;
const match = DURATION_PATTERN.exec(normalized);
if (!match) return resetAt.subtract(1, "day");
const value = Number(match[1]);
switch (match[2]) {
case "mo":
return resetAt.subtract(value, "month");
case "d":
return value === 30 ? resetAt.subtract(1, "month") : resetAt.subtract(value, "day");
case "w":
return resetAt.subtract(value, "week");
case "h":
return resetAt.subtract(value, "hour");
case "m":
return resetAt.subtract(value, "minute");
default:
return resetAt.subtract(value, "second");
}
}
export function formatBudgetPeriod(start: Dayjs, end: Dayjs): string {
const windowSeconds = end.diff(start, "second");
if (windowSeconds < 24 * 60 * 60) {
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)}`;
}
if (start.isSame(end, "year")) return `${start.format("MMM D")} - ${end.format("MMM D, YYYY")}`;
return `${start.format("MMM D, YYYY")} - ${end.format("MMM D, YYYY")}`;
}
export function describeBudgetResetSchedule(
budgetDuration: string | null | undefined,
@ -31,26 +76,33 @@ export function describeBudgetResetSchedule(
): BudgetResetSchedule {
const resetDate = budgetResetAt ? dayjs(budgetResetAt) : null;
if (resetDate?.isValid()) {
const cadence = budgetDuration ? `every ${budgetDuration}` : "on a schedule";
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const label = resetDate.format("MMM D, YYYY HH:mm");
const period = budgetDuration
? formatBudgetPeriod(getBudgetPeriodStart(budgetDuration, resetDate.utc()).local(), resetDate)
: null;
const coverage = period
? `Current Period Spend covers ${period} and resets every ${budgetDuration}.`
: "Current Period Spend resets on a schedule.";
return {
label,
resetAt: resetDate.toISOString(),
tooltip: `Current Cycle Spend resets ${cadence}. Next reset ${label} (${timeZone}). ${ROLLOVER_NOTE}`,
period,
tooltip: `${coverage} Next reset ${label} (${timeZone}). ${ROLLOVER_NOTE}`,
};
}
if (!budgetDuration) {
return {
label: "Never resets",
resetAt: null,
tooltip:
"This budget has no reset window, so Current Cycle Spend accumulates for as long as the membership exists.",
period: null,
tooltip: "This budget has no reset window, so this is lifetime spend for as long as the membership exists.",
};
}
return {
label: "Unknown",
resetAt: null,
period: null,
tooltip: `Resets every ${budgetDuration}, but the next reset time has not been scheduled yet. ${ROLLOVER_NOTE}`,
};
}