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;