feat(usage): show when each report was fetched

The usage card reads fetchedAt as "Updated 3m ago": a tooltip on the
card header where hover exists (web, desktop), printed in the card
footer on native and compact. Shared by the pill popover and the Usage
screen.
This commit is contained in:
Mohamed Boudra
2026-09-27 12:41:45 +02:00
parent 3b98661281
commit 207da17d9a
5 changed files with 92 additions and 24 deletions
+14 -5
View File
@@ -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 }) => {
+62 -19
View File
@@ -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 (
<View style={containerStyle}>
<View style={styles.header}>
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
</View>
<Tooltip delayDuration={300} enabledOnDesktop={!isNative}>
<TooltipTrigger style={styles.header} accessible={false} testID="usage-card-header">
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
</TooltipTrigger>
<TooltipContent side="top" align="start" testID="usage-freshness-tooltip">
<UsageFreshness fetchedAt={entry.fetchedAt} style={styles.tooltipText} />
</TooltipContent>
</Tooltip>
{usage.error ? (
<Text style={styles.error} numberOfLines={3}>
@@ -87,15 +100,30 @@ export function UsageCard({
</View>
) : null}
{footer ? (
<Text style={styles.footer} numberOfLines={1}>
{footer}
</Text>
{footer || showsFreshnessInline ? (
<View style={styles.footerRow}>
<Text style={styles.footer} numberOfLines={1}>
{footer}
</Text>
{showsFreshnessInline ? (
<UsageFreshness fetchedAt={entry.fetchedAt} style={styles.freshness} />
) : null}
</View>
) : null}
</View>
);
}
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
function UsageFreshness({ fetchedAt, style }: { fetchedAt: string; style: StyleProp<TextStyle> }) {
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
return (
<Text style={style} numberOfLines={1} testID="usage-freshness">
{formatUsageFreshness(elapsed)}
</Text>
);
}
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,
},
}));
+1
View File
@@ -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",
+9
View File
@@ -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");
});
});
+6
View File
@@ -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;