feat(ui): show exact license expiration date in usage cards

This commit is contained in:
Yuneng Jiang 2026-07-15 16:52:21 -07:00
parent ff4a40f017
commit 056f85a71e
No known key found for this signature in database
6 changed files with 94 additions and 30 deletions

View file

@ -128,6 +128,29 @@ describe("SidebarUsageCard", () => {
expect(container.querySelector('[data-slot="meter"]')).toBeNull();
});
it("shows the exact license expiration date as the subtitle instead of time remaining", async () => {
mockUseLicenseInfo.mockReturnValue(licenseResult({ ...ACTIVE_LICENSE, expiration_date: "2099-12-31" }));
renderWithClient(<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />);
expect(await screen.findByText("Expires Dec 31, 2099")).toBeInTheDocument();
expect(screen.queryByText(/(day|days|month|months) remaining/)).not.toBeInTheDocument();
});
it("shows the exact date as the subtitle when the license is expired", async () => {
mockUseLicenseInfo.mockReturnValue(licenseResult({ ...ACTIVE_LICENSE, expiration_date: "2020-01-01" }));
renderWithClient(<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />);
expect(await screen.findByText("Expired Jan 1, 2020")).toBeInTheDocument();
});
it("falls back to Active plan when the license has no expiration date", async () => {
renderWithClient(<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />);
expect(await screen.findByText("Active plan")).toBeInTheDocument();
});
it("shows a collapsed rail button that expands the sidebar", async () => {
const onExpandRail = vi.fn();
const user = userEvent.setup();

View file

@ -1,6 +1,6 @@
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
import { getDaysUntilExpiration } from "@/utils/licenseUtils";
import { formatExpirationStatus } from "@/utils/licenseUtils";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "@/components/ui/meter";
@ -20,16 +20,6 @@ interface MeterData {
total: number;
}
const formatExpiration = (daysRemaining: number | null): string => {
if (daysRemaining === null) return "No expiration";
if (daysRemaining < 0) return "Expired";
if (daysRemaining === 0) return "Expires today";
if (daysRemaining === 1) return "1 day remaining";
if (daysRemaining < 30) return `${daysRemaining} days remaining`;
if (daysRemaining < 60) return "1 month remaining";
return `${Math.floor(daysRemaining / 30)} months remaining`;
};
const meterTone = (pct: number): "default" | "warning" | "over" => {
if (pct > 100) return "over";
if (pct >= 80) return "warning";
@ -104,8 +94,7 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail
);
}
const daysUntilExpiration = licenseInfo?.expiration_date ? getDaysUntilExpiration(licenseInfo.expiration_date) : null;
const subtitle = licenseInfo?.expiration_date ? formatExpiration(daysUntilExpiration) : "Active plan";
const subtitle = licenseInfo?.expiration_date ? formatExpirationStatus(licenseInfo.expiration_date) : "Active plan";
const meters = buildMeters(data);
return (

View file

@ -14,9 +14,19 @@ vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({
useDisableUsageIndicator: vi.fn(() => false),
}));
import { getRemainingUsers } from "./networking";
import { getLicenseInfo, getRemainingUsers } from "./networking";
import type { LicenseInfo } from "./networking";
const mockGetRemainingUsers = vi.mocked(getRemainingUsers);
const mockGetLicenseInfo = vi.mocked(getLicenseInfo);
const licenseWithExpiry = (expiration_date: string): LicenseInfo => ({
has_license: true,
license_type: "enterprise",
expiration_date,
allowed_features: [],
limits: { max_users: null, max_teams: null },
});
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@ -36,6 +46,7 @@ describe("UsageIndicator", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetRemainingUsers.mockResolvedValue(DEFAULT_USAGE_DATA);
mockGetLicenseInfo.mockResolvedValue(null);
});
it("should render when given access token and usage data loads", async () => {
@ -126,6 +137,23 @@ describe("UsageIndicator", () => {
expect(screen.getByText("Over limit")).toBeInTheDocument();
});
it("should show the exact license expiration date instead of time remaining", async () => {
mockGetLicenseInfo.mockResolvedValue(licenseWithExpiry("2099-12-31"));
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
expect(await screen.findByText("Expires Dec 31, 2099")).toBeInTheDocument();
expect(screen.queryByText(/(day|days|month|months) remaining/)).not.toBeInTheDocument();
});
it("should show the exact date when the license is expired", async () => {
mockGetLicenseInfo.mockResolvedValue(licenseWithExpiry("2020-01-01"));
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
expect(await screen.findByText("Expired Jan 1, 2020")).toBeInTheDocument();
});
it("should render nothing when accessToken is null", () => {
renderWithClient(<UsageIndicator accessToken={null} width={220} />);

View file

@ -15,7 +15,7 @@ import { useEffect, useState } from "react";
import { getRemainingUsers } from "./networking";
import { cn } from "@/lib/cva.config";
import { getDaysUntilExpiration } from "@/utils/licenseUtils";
import { formatExpirationStatus, getDaysUntilExpiration } from "@/utils/licenseUtils";
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
interface UsageIndicatorProps {
@ -32,18 +32,6 @@ interface UsageData {
total_teams_remaining: number | null;
}
// Format expiration for display
const formatExpirationDisplay = (daysRemaining: number | null): string => {
if (daysRemaining === null) return "No expiration";
if (daysRemaining < 0) return "Expired";
if (daysRemaining === 0) return "Expires today";
if (daysRemaining === 1) return "1 day remaining";
if (daysRemaining < 30) return `${daysRemaining} days remaining`;
if (daysRemaining < 60) return "1 month remaining";
const months = Math.floor(daysRemaining / 30);
return `${months} months remaining`;
};
export default function UsageIndicator({ accessToken, width = 220 }: UsageIndicatorProps) {
const disableUsageIndicator = useDisableUsageIndicator();
const [isExpanded, setIsExpanded] = useState(false);
@ -292,7 +280,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
) : isLicenseExpiringSoon ? (
<TrendingUp className="h-3 w-3" />
) : null}
<span className="truncate">{formatExpirationDisplay(daysUntilExpiration)}</span>
<span className="truncate">{formatExpirationStatus(licenseInfo.expiration_date)}</span>
</div>
</div>
)}
@ -529,7 +517,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
isLicenseExpiringSoon && "text-yellow-600",
)}
>
{formatExpirationDisplay(daysUntilExpiration)}
{formatExpirationStatus(licenseInfo.expiration_date)}
</span>
</div>
{licenseInfo.license_type && (

View file

@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { type LicenseExpiryTier, formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "./licenseUtils";
import {
type LicenseExpiryTier,
formatExpirationStatus,
formatExpiryDate,
getDaysUntilExpiration,
getLicenseExpiryTier,
} from "./licenseUtils";
const NOW = new Date("2026-07-08T00:00:00Z");
@ -59,3 +65,25 @@ describe("formatExpiryDate", () => {
expect(formatExpiryDate("bogus")).toBe("bogus");
});
});
describe("formatExpirationStatus", () => {
it("shows the exact date for a future expiration", () => {
expect(formatExpirationStatus("2026-08-07", NOW)).toBe("Expires Aug 7, 2026");
});
it("still reads as upcoming on the expiration day itself", () => {
expect(formatExpirationStatus("2026-07-08", NOW)).toBe("Expires Jul 8, 2026");
});
it("shows the exact date for a past expiration", () => {
expect(formatExpirationStatus("2026-07-07", NOW)).toBe("Expired Jul 7, 2026");
});
it("returns No expiration for a null date", () => {
expect(formatExpirationStatus(null, NOW)).toBe("No expiration");
});
it("returns No expiration for an unparseable date", () => {
expect(formatExpirationStatus("not-a-date", NOW)).toBe("No expiration");
});
});

View file

@ -49,3 +49,11 @@ export const formatExpiryDate = (expirationDate: string): string => {
}
return date.toLocaleDateString("en-US", EXPIRY_DATE_FORMAT);
};
export const formatExpirationStatus = (expirationDate: string | null, now: Date = new Date()): string => {
const days = getDaysUntilExpiration(expirationDate, now);
if (expirationDate === null || days === null) {
return "No expiration";
}
return days < 0 ? `Expired ${formatExpiryDate(expirationDate)}` : `Expires ${formatExpiryDate(expirationDate)}`;
};