diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts
index 0158beff5e..a504af468a 100644
--- a/packages/app/e2e/browser/usage-screen.spec.ts
+++ b/packages/app/e2e/browser/usage-screen.spec.ts
@@ -3,6 +3,11 @@ import { gotoAppShell } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
+// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
+function twoHoursAgo(): string {
+ return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
+}
+
test.describe("usage screen", () => {
test("opens from the sidebar and groups reports under their host", async ({ page }) => {
test.setTimeout(120_000);
@@ -13,7 +18,7 @@ test.describe("usage screen", () => {
{
id: "alpha:a",
account: {},
- fetchedAt: "2026-01-01T00:00:00.000Z",
+ fetchedAt: twoHoursAgo(),
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: {
@@ -43,6 +48,10 @@ test.describe("usage screen", () => {
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
+
+ await group.getByTestId("usage-card-header").first().hover();
+ await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
+ await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
});
test("shows the host once it connects after a cold load on a phone", async ({ page }) => {
@@ -54,7 +63,7 @@ test.describe("usage screen", () => {
{
id: "alpha:a",
account: {},
- fetchedAt: "2026-01-01T00:00:00.000Z",
+ fetchedAt: twoHoursAgo(),
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { status: "available", windows: [] },
@@ -67,9 +76,9 @@ test.describe("usage screen", () => {
await page.goto("/usage");
await usage.waitForListRequests(1);
- await expect(
- page.getByTestId(`usage-host-${serverId}`).getByText("Alpha plan", { exact: true }),
- ).toBeVisible({ timeout: 10_000 });
+ const group = page.getByTestId(`usage-host-${serverId}`);
+ await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
+ await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
});
test("tells the user to update a host without usage sources", async ({ page }) => {
diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx
index d432f80cdf..62ea24efc1 100644
--- a/packages/app/src/usage/card.tsx
+++ b/packages/app/src/usage/card.tsx
@@ -1,8 +1,13 @@
import { useMemo } from "react";
-import { Text, View } from "react-native";
+import { Text, View, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StatusBadge } from "@/components/ui/status-badge";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { useIsCompactFormFactor } from "@/constants/layout";
+import { isNative } from "@/constants/platform";
+import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
import { UsageBalanceBar } from "./balance-bar";
+import { formatUsageFreshness } from "./model";
import { UsageSourceIcon } from "./source-icon";
import type { UsageReport, UsageReportEntry } from "./types";
import { UsageWindowBar } from "./window-bar";
@@ -19,6 +24,9 @@ export function UsageCard({
entry: UsageReportEntry;
compact?: boolean;
}) {
+ const isCompact = useIsCompactFormFactor();
+ // Where there is no hover the freshness is printed on the card; elsewhere the header's tooltip.
+ const showsFreshnessInline = isNative || isCompact;
const usage = entry.report;
const status = statusText(usage);
const footer = entry.account.label ?? null;
@@ -40,20 +48,25 @@ export function UsageCard({
return (
-
-
-
- {entry.sourceLabel}
-
- {usage.planLabel ? : null}
-
- {status ? (
-
-
- {status}
-
- ) : null}
-
+
+
+
+
+ {entry.sourceLabel}
+
+ {usage.planLabel ? : null}
+
+ {status ? (
+
+
+ {status}
+
+ ) : null}
+
+
+
+
+
{usage.error ? (
@@ -87,15 +100,30 @@ export function UsageCard({
) : null}
- {footer ? (
-
- {footer}
-
+ {footer || showsFreshnessInline ? (
+
+
+ {footer}
+
+ {showsFreshnessInline ? (
+
+ ) : null}
+
) : null}
);
}
+/** Its own component so the relative-time clock re-renders one ``, not the card. */
+function UsageFreshness({ fetchedAt, style }: { fetchedAt: string; style: StyleProp }) {
+ const elapsed = useCompactTimeAgo(new Date(fetchedAt));
+ return (
+
+ {formatUsageFreshness(elapsed)}
+
+ );
+}
+
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
@@ -167,8 +195,23 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.4,
},
+ footerRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
footer: {
+ flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
+ freshness: {
+ flexShrink: 0,
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ tooltipText: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.base,
+ },
}));
diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts
index b8330f43d6..4c8b622eae 100644
--- a/packages/app/src/usage/copy.ts
+++ b/packages/app/src/usage/copy.ts
@@ -5,6 +5,7 @@ export const usageCopy = {
planUsage: "Plan usage",
refresh: "Refresh",
refreshing: "Refreshing...",
+ updated: "Updated",
loading: "Loading usage...",
empty: "No usage data",
noHosts: "No connected hosts",
diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts
index e08155fcfc..6773742f60 100644
--- a/packages/app/src/usage/model.test.ts
+++ b/packages/app/src/usage/model.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ formatUsageFreshness,
groupUsageByHost,
headlineWindow,
resolveUsagePill,
@@ -170,3 +171,11 @@ describe("groupUsageByHost", () => {
]);
});
});
+
+describe("formatUsageFreshness", () => {
+ it("says when the report was fetched", () => {
+ expect(formatUsageFreshness("now")).toBe("Updated just now");
+ expect(formatUsageFreshness("3m")).toBe("Updated 3m ago");
+ expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15");
+ });
+});
diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts
index ef74cdcdae..b8c50a5bd7 100644
--- a/packages/app/src/usage/model.ts
+++ b/packages/app/src/usage/model.ts
@@ -1,3 +1,4 @@
+import { formatCompactTimeAgoAsProse } from "@/utils/time";
import { usageCopy } from "./copy";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
@@ -37,6 +38,11 @@ export function resolveUsagePill(input: {
};
}
+/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
+export function formatUsageFreshness(compactTimeAgo: string): string {
+ return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
+}
+
export interface UsageQueryState {
data: UsageReportEntry[] | undefined;
error: unknown;