diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx
index 7702488f5bf..2db209b444f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx
@@ -64,8 +64,9 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({
default: () =>
Endpoint Usage
,
}));
-vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
+vi.mock("./UsageViewSelect/UsageViewSelect", async (importOriginal) => {
const React = await import("react");
+ const actual = await importOriginal();
const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => {
const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null;
return React.createElement(
@@ -87,7 +88,7 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
);
};
UsageViewSelect.displayName = "UsageViewSelect";
- return { UsageViewSelect };
+ return { ...actual, UsageViewSelect };
});
vi.mock("@/components/shared/advanced_date_picker", async () => {
@@ -353,6 +354,10 @@ describe("UsagePage", () => {
};
beforeEach(() => {
+ // UsagePageView now persists the selected view/tab/date-range to
+ // localStorage; without clearing it here, whichever test runs first
+ // pollutes every test after it in this file.
+ localStorage.clear();
mockUseAuthorized.mockReturnValue({
isLoading: false,
isAuthorized: true,
@@ -1364,5 +1369,68 @@ describe("UsagePage", () => {
expect(screen.getByText("MCP Server Activity")).toBeInTheDocument();
expect(screen.getByText("Endpoint Activity")).toBeInTheDocument();
});
+
+ it("should persist the selected activity tab across a reload", async () => {
+ const { unmount } = renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
+ });
+
+ act(() => {
+ fireEvent.click(screen.getByText("Model Activity"));
+ });
+
+ await waitFor(() => {
+ expect(localStorage.getItem("litellmUsageActivityTab")).toBe("models");
+ });
+
+ unmount();
+ mockUserDailyActivityAggregatedCall.mockClear();
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
+ });
+
+ expect(screen.getByRole("tab", { name: "Model Activity" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tab", { name: "Cost" })).toHaveAttribute("aria-selected", "false");
+ });
+ });
+
+ describe("persisted usage view", () => {
+ it.each(["agent", "organization", "customer", "user"])(
+ "should not restore a persisted %s usage view for a session without access to it",
+ async (persistedView) => {
+ // The persisted view can outlive the session that set it (role change,
+ // org-admin membership revoked, a different account signing in on the
+ // same browser). Restoring it verbatim would land a non-admin on a
+ // selector value with no matching panel and no way to get back except
+ // manually reselecting a different option.
+ localStorage.setItem("litellmUsageView", persistedView);
+ mockUseAuthorized.mockReturnValue(nonAdminSession);
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
+ });
+
+ expect((screen.getByTestId("usage-view-select") as HTMLSelectElement).value).toBe("global");
+ },
+ );
+
+ it("should restore a persisted usage view that the session still has access to", async () => {
+ localStorage.setItem("litellmUsageView", "team");
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
+ });
+
+ expect((screen.getByTestId("usage-view-select") as HTMLSelectElement).value).toBe("team");
+ });
});
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx
index 018dfc84740..e584235e093 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx
@@ -42,6 +42,8 @@ import {
} from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { ChartLoader } from "@/components/shared/chart_loader";
+import { getMatchingRelativeOption, getRelativeRangeByShortLabel } from "@/components/shared/date_range_presets";
+import { getLocalStorageItem, removeLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
import { Tag } from "@/components/tag_management/types";
import UserAgentActivity from "@/components/user_agent_activity";
import ViewUserSpend from "@/components/view_user_spend";
@@ -65,13 +67,87 @@ import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations";
import UsageAIChatPanel from "./UsageAIChatPanel";
-import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
+import { isUsageOptionAvailable, UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
interface UsagePageProps {
teams: Team[];
organizations: Organization[];
}
+const USAGE_DATE_RANGE_PRESET_KEY = "litellmUsageDateRangePreset";
+const USAGE_DATE_RANGE_CUSTOM_KEY = "litellmUsageDateRangeCustom";
+const USAGE_VIEW_KEY = "litellmUsageView";
+const USAGE_ACTIVITY_TAB_KEY = "litellmUsageActivityTab";
+
+const VALID_ACTIVITY_TABS = ["cost", "models", "keys", "mcp", "endpoints"] as const;
+type ActivityTab = (typeof VALID_ACTIVITY_TABS)[number];
+
+const VALID_USAGE_OPTIONS: UsageOption[] = [
+ "global",
+ "my-usage",
+ "organization",
+ "team",
+ "customer",
+ "tag",
+ "agent",
+ "user",
+ "user-agent-activity",
+];
+
+const DEFAULT_DATE_RANGE = (): DateRangePickerValue => ({
+ from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
+ to: new Date(),
+});
+
+/** Resolves the persisted time range. Relative presets ("7d", "today", ...) are
+ * recomputed fresh from `now` on every load, so a saved preset never goes stale. */
+function loadPersistedDateRange(): DateRangePickerValue {
+ const presetLabel = getLocalStorageItem(USAGE_DATE_RANGE_PRESET_KEY);
+ if (presetLabel) {
+ const range = getRelativeRangeByShortLabel(presetLabel);
+ if (range) return range;
+ }
+
+ const customRaw = getLocalStorageItem(USAGE_DATE_RANGE_CUSTOM_KEY);
+ if (customRaw) {
+ try {
+ const { from, to } = JSON.parse(customRaw) as { from: string; to: string };
+ const fromDate = new Date(from);
+ const toDate = new Date(to);
+ if (!isNaN(fromDate.getTime()) && !isNaN(toDate.getTime())) {
+ return { from: fromDate, to: toDate };
+ }
+ } catch {
+ // fall through to default
+ }
+ }
+
+ return DEFAULT_DATE_RANGE();
+}
+
+function persistDateRange(value: DateRangePickerValue): void {
+ const presetLabel = getMatchingRelativeOption(value);
+ if (presetLabel) {
+ setLocalStorageItem(USAGE_DATE_RANGE_PRESET_KEY, presetLabel);
+ removeLocalStorageItem(USAGE_DATE_RANGE_CUSTOM_KEY);
+ return;
+ }
+ removeLocalStorageItem(USAGE_DATE_RANGE_PRESET_KEY);
+ if (value.from && value.to) {
+ setLocalStorageItem(USAGE_DATE_RANGE_CUSTOM_KEY, JSON.stringify({ from: value.from, to: value.to }));
+ }
+}
+
+function loadPersistedUsageView(): UsageOption {
+ const stored = getLocalStorageItem(USAGE_VIEW_KEY);
+ return VALID_USAGE_OPTIONS.includes(stored as UsageOption) ? (stored as UsageOption) : "global";
+}
+
+function loadPersistedActivityTab(): ActivityTab {
+ const stored = getLocalStorageItem(USAGE_ACTIVITY_TAB_KEY);
+ return (VALID_ACTIVITY_TABS as readonly string[]).includes(stored ?? "") ? (stored as ActivityTab) : "cost";
+}
+
const UsagePage: React.FC = ({ teams, organizations }) => {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
// Aggregated endpoint: try first, fall back to paginated if unavailable
@@ -89,15 +165,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
// Separate loading states for better UX
const [isDateChanging, setIsDateChanging] = useState(false);
- // Create initial dates outside of state to prevent recreation
- const initialFromDate = useMemo(() => new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), []);
- const initialToDate = useMemo(() => new Date(), []);
-
- // Single date state that directly triggers data fetching
- const [dateValue, setDateValue] = useState({
- from: initialFromDate,
- to: initialToDate,
- });
+ // Single date state that directly triggers data fetching. Initialized from
+ // localStorage so a saved selection survives a page reload; relative presets
+ // ("7d", "today", ...) are recomputed fresh here rather than replayed as a
+ // frozen timestamp, so "now" always tracks the actual current day.
+ const [dateValue, setDateValue] = useState(() => loadPersistedDateRange());
const [fetchedTags, setFetchedTags] = useState | null>(null);
// No [] default: an unresolved query must stay undefined so the customer
@@ -118,14 +190,29 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
- const [selectedUsageView, setUsageView] = useState("global");
- // Org-admin membership is read from the server, so unlike the other usage
- // views this one can be revoked while the page is open. Derive the view in
- // render rather than storing it, so the fallback lands on the same paint and
- // the selector never holds a value it no longer offers.
- const usageView: UsageOption =
- selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView;
+ const [selectedUsageView, setUsageViewState] = useState(() => loadPersistedUsageView());
+ const setUsageView = useCallback((value: UsageOption) => {
+ setUsageViewState(value);
+ setLocalStorageItem(USAGE_VIEW_KEY, value);
+ }, []);
+ // Capabilities (org-admin membership, tag usage) are read from the server and
+ // a persisted view is restored from localStorage, so either can grant a view
+ // the current session no longer has. Derive the view in render rather than
+ // storing it, so the fallback lands on the same paint and the selector never
+ // holds a value it no longer offers.
+ const usageView: UsageOption = isUsageOptionAvailable(selectedUsageView, {
+ userRole,
+ canViewTagUsage,
+ isOrgAdmin,
+ })
+ ? selectedUsageView
+ : "global";
+ const [activityTab, setActivityTabState] = useState(() => loadPersistedActivityTab());
+ const setActivityTab = useCallback((value: string) => {
+ setActivityTabState(value as ActivityTab);
+ setLocalStorageItem(USAGE_ACTIVITY_TAB_KEY, value);
+ }, []);
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
const [topKeysLimit, setTopKeysLimit] = useState(5);
const [topModelsLimit, setTopModelsLimit] = useState(5);
@@ -273,8 +360,35 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
// Update date immediately for UI responsiveness
setDateValue(newValue);
+ persistDateRange(newValue);
}, []);
+ // If the user leaves this tab open across midnight (or backgrounded on
+ // mobile), a relative preset like "Today"/"7d" should track the real
+ // current day again once they come back, not stay pinned to load time.
+ useEffect(() => {
+ const refreshPresetIfStale = () => {
+ const presetLabel = getMatchingRelativeOption(dateValue);
+ if (!presetLabel) return;
+ const freshRange = getRelativeRangeByShortLabel(presetLabel);
+ if (!freshRange) return;
+ if (
+ freshRange.from.getTime() !== dateValue.from?.getTime() ||
+ freshRange.to.getTime() !== dateValue.to?.getTime()
+ ) {
+ setDateValue(freshRange);
+ persistDateRange(freshRange);
+ }
+ };
+
+ document.addEventListener("visibilitychange", refreshPresetIfStale);
+ window.addEventListener("focus", refreshPresetIfStale);
+ return () => {
+ document.removeEventListener("visibilitychange", refreshPresetIfStale);
+ window.removeEventListener("focus", refreshPresetIfStale);
+ };
+ }, [dateValue]);
+
// Derived states from userSpendData
const totalSpend = userSpendData.metadata?.total_spend || 0;
@@ -469,7 +583,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
)}
-
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx
index ea21a2155c1..7ac7cbe5830 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx
@@ -104,6 +104,37 @@ const OPTIONS: OptionConfig[] = [
adminOnly: true,
},
];
+export interface UsageOptionAvailability {
+ userRole: string | null;
+ canViewTagUsage?: boolean;
+ isOrgAdmin?: boolean;
+}
+
+/**
+ * Single source of truth for whether a given usage view is available to the
+ * current user, mirroring the capability matrix in OPTIONS below. Used both
+ * to filter the select's menu and to validate a usage view restored from
+ * persisted state (e.g. localStorage), whose role may have changed since.
+ */
+export const isUsageOptionAvailable = (
+ value: UsageOption,
+ { userRole, canViewTagUsage = false, isOrgAdmin = false }: UsageOptionAvailability,
+): boolean => {
+ const option = OPTIONS.find((o) => o.value === value);
+ if (!option) return false;
+ const isAdmin = all_admin_roles.includes(userRole ?? "");
+ if (option.capability) {
+ return hasCapability(userRole, option.capability, isOrgAdmin);
+ }
+ if (option.value === "tag" && canViewTagUsage) {
+ return true;
+ }
+ if (option.adminOnly && !isAdmin) {
+ return false;
+ }
+ return true;
+};
+
export const UsageViewSelect: React.FC = ({
value,
onChange,
@@ -116,18 +147,9 @@ export const UsageViewSelect: React.FC = ({
}) => {
const isAdmin = all_admin_roles.includes(userRole ?? "");
const getFilteredOptions = () => {
- return OPTIONS.filter((option) => {
- if (option.capability) {
- return hasCapability(userRole, option.capability, isOrgAdmin);
- }
- if (option.value === "tag" && canViewTagUsage) {
- return true;
- }
- if (option.adminOnly && !isAdmin) {
- return false;
- }
- return true;
- }).map((option) => {
+ return OPTIONS.filter((option) =>
+ isUsageOptionAvailable(option.value, { userRole, canViewTagUsage, isOrgAdmin }),
+ ).map((option) => {
let label = option.label;
let desc = option.description;
if (option.showForAdmin && option.showForNonAdmin) {
diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx
index 78e258d1a69..e157e25b713 100644
--- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx
+++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.test.tsx
@@ -235,4 +235,43 @@ describe("AdvancedDatePicker", () => {
expect(applyButton).toBeDisabled();
});
});
+
+ it("should close dropdown when Apply is clicked", async () => {
+ const { container } = render();
+
+ openDropdown(container);
+ expect(screen.getByText("Today")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("Apply"));
+
+ await waitFor(() => {
+ expect(screen.queryByText("Today")).not.toBeInTheDocument();
+ });
+ });
+
+ it("should close dropdown on Apply even without requestIdleCallback (Safari)", async () => {
+ // Safari has never implemented requestIdleCallback. Simulate that by
+ // removing the polyfill installed in the beforeAll above for this one
+ // test, so a regression that puts setIsOpen(false) after the
+ // requestIdleCallback call (and lets its ReferenceError swallow it)
+ // fails here instead of only in real Safari.
+ const original = window.requestIdleCallback;
+ // @ts-expect-error - deleting a browser API to emulate Safari's absence of it
+ delete window.requestIdleCallback;
+
+ try {
+ const { container } = render();
+
+ openDropdown(container);
+ expect(screen.getByText("Today")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("Apply"));
+
+ await waitFor(() => {
+ expect(screen.queryByText("Today")).not.toBeInTheDocument();
+ });
+ } finally {
+ window.requestIdleCallback = original;
+ }
+ });
});
diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx
index 190e3de4a82..cd1d7a8b3b5 100644
--- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx
+++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx
@@ -4,6 +4,11 @@ import { cn } from "@/lib/cva.config";
import type { DateRangePickerValue } from "./date_picker_types";
import moment from "moment";
import React, { useCallback, useEffect, useRef, useState } from "react";
+import {
+ getMatchingRelativeOption,
+ RelativeTimeOption,
+ relativeTimeOptions,
+} from "@/components/shared/date_range_presets";
interface AdvancedDatePickerProps {
value: DateRangePickerValue;
@@ -14,55 +19,6 @@ interface AdvancedDatePickerProps {
align?: "left" | "right";
}
-interface RelativeTimeOption {
- label: string;
- shortLabel: string;
- getValue: () => { from: Date; to: Date };
-}
-
-const relativeTimeOptions: RelativeTimeOption[] = [
- {
- label: "Today",
- shortLabel: "today",
- getValue: () => ({
- from: moment().startOf("day").toDate(),
- to: moment().endOf("day").toDate(),
- }),
- },
- {
- label: "Last 7 days",
- shortLabel: "7d",
- getValue: () => ({
- from: moment().subtract(7, "days").startOf("day").toDate(),
- to: moment().endOf("day").toDate(),
- }),
- },
- {
- label: "Last 30 days",
- shortLabel: "30d",
- getValue: () => ({
- from: moment().subtract(30, "days").startOf("day").toDate(),
- to: moment().endOf("day").toDate(),
- }),
- },
- {
- label: "Month to date",
- shortLabel: "MTD",
- getValue: () => ({
- from: moment().startOf("month").toDate(),
- to: moment().endOf("day").toDate(),
- }),
- },
- {
- label: "Year to date",
- shortLabel: "YTD",
- getValue: () => ({
- from: moment().startOf("year").toDate(),
- to: moment().endOf("day").toDate(),
- }),
- },
-];
-
/**
* Advanced Date Range Picker with dropdown, relative times, and custom inputs
*/
@@ -85,23 +41,10 @@ const AdvancedDatePicker: React.FC = ({
const dropdownRef = useRef(null);
// Function to check if current value matches a relative time option
- const getMatchingOption = useCallback((currentValue: DateRangePickerValue): string | null => {
- if (!currentValue.from || !currentValue.to) return null;
-
- for (const option of relativeTimeOptions) {
- const optionRange = option.getValue();
-
- // Compare dates with some tolerance (to account for time differences)
- const fromMatches = moment(currentValue.from).isSame(moment(optionRange.from), "day");
- const toMatches = moment(currentValue.to).isSame(moment(optionRange.to), "day");
-
- if (fromMatches && toMatches) {
- return option.shortLabel;
- }
- }
-
- return null;
- }, []);
+ const getMatchingOption = useCallback(
+ (currentValue: DateRangePickerValue): string | null => getMatchingRelativeOption(currentValue),
+ [],
+ );
// Update selected option when value changes
useEffect(() => {
@@ -245,16 +188,21 @@ const AdvancedDatePicker: React.FC = ({
// First call with immediate value for UI responsiveness
onValueChange(tempValue);
- // Then do the same background adjustment logic as the original component
- requestIdleCallback(
- () => {
- const adjustedValue = adjustDateRange(tempValue);
- onValueChange(adjustedValue);
- },
- { timeout: 100 },
- );
-
+ // Close immediately - Safari has no requestIdleCallback, and scheduling
+ // the close after it (as before) meant the thrown ReferenceError there
+ // silently skipped this line, leaving the dropdown stuck open.
setIsOpen(false);
+
+ // Then do the same background adjustment logic as the original component.
+ // Safari doesn't implement requestIdleCallback, so fall back to setTimeout.
+ const scheduleIdle: (callback: () => void) => void =
+ typeof requestIdleCallback === "function"
+ ? (callback) => requestIdleCallback(callback, { timeout: 100 })
+ : (callback) => setTimeout(callback, 0);
+ scheduleIdle(() => {
+ const adjustedValue = adjustDateRange(tempValue);
+ onValueChange(adjustedValue);
+ });
}
};
diff --git a/ui/litellm-dashboard/src/components/shared/date_range_presets.ts b/ui/litellm-dashboard/src/components/shared/date_range_presets.ts
new file mode 100644
index 00000000000..a19b27aa44a
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/date_range_presets.ts
@@ -0,0 +1,79 @@
+import moment from "moment";
+
+// Structurally compatible with @tremor/react's DateRangePickerValue, defined
+// locally so this module doesn't need a (now-discouraged) tremor import.
+interface DateRangeLike {
+ from?: Date;
+ to?: Date;
+}
+
+export interface RelativeTimeOption {
+ label: string;
+ shortLabel: string;
+ getValue: () => { from: Date; to: Date };
+}
+
+export const relativeTimeOptions: RelativeTimeOption[] = [
+ {
+ label: "Today",
+ shortLabel: "today",
+ getValue: () => ({
+ from: moment().startOf("day").toDate(),
+ to: moment().endOf("day").toDate(),
+ }),
+ },
+ {
+ label: "Last 7 days",
+ shortLabel: "7d",
+ getValue: () => ({
+ from: moment().subtract(7, "days").startOf("day").toDate(),
+ to: moment().endOf("day").toDate(),
+ }),
+ },
+ {
+ label: "Last 30 days",
+ shortLabel: "30d",
+ getValue: () => ({
+ from: moment().subtract(30, "days").startOf("day").toDate(),
+ to: moment().endOf("day").toDate(),
+ }),
+ },
+ {
+ label: "Month to date",
+ shortLabel: "MTD",
+ getValue: () => ({
+ from: moment().startOf("month").toDate(),
+ to: moment().endOf("day").toDate(),
+ }),
+ },
+ {
+ label: "Year to date",
+ shortLabel: "YTD",
+ getValue: () => ({
+ from: moment().startOf("year").toDate(),
+ to: moment().endOf("day").toDate(),
+ }),
+ },
+];
+
+/** Returns the shortLabel of the relative preset that matches `value`, or null if it's a custom range. */
+export function getMatchingRelativeOption(value: DateRangeLike): string | null {
+ if (!value.from || !value.to) return null;
+
+ for (const option of relativeTimeOptions) {
+ const optionRange = option.getValue();
+ const fromMatches = moment(value.from).isSame(moment(optionRange.from), "day");
+ const toMatches = moment(value.to).isSame(moment(optionRange.to), "day");
+ if (fromMatches && toMatches) {
+ return option.shortLabel;
+ }
+ }
+
+ return null;
+}
+
+/** Recomputes a fresh {from, to} for a stored preset shortLabel (e.g. "today" always resolves to the current day). */
+export function getRelativeRangeByShortLabel(shortLabel: string): { from: Date; to: Date } | null {
+ const option = relativeTimeOptions.find((o) => o.shortLabel === shortLabel);
+ return option ? option.getValue() : null;
+}