Files
paseo/packages/app/src/usage/card.tsx
T
paseo-bot[bot]andMohamed Boudra 05874e289a Keep relative timestamps current in agent, schedule, and import-session rows (#5340)
* Keep relative timestamps current in agent, schedule, and import-session rows

Agent list rows, schedule rows, and import-session rows formatted their
"5m ago" labels once at render, so an idle row kept its first label until
something else re-rendered it. They now use useTimeAgo, which rides the
same shared ticker the sidebar and desktop tabs already use.

Closes #4625

* Say "just now" for the whole first minute so fresh labels stay honest

The shared ticker wakes once a minute, so a seconds label could only go
stale. The prose and compact formatters now share one age description,
and the schedule spec anchors its clock to the schedule's creation time.

* Run every cleanup in the schedule relative-time spec and name its steps

* Keep relative labels current with focused subscriptions

* Update usage card after relative-time hook rename

* Anchor import-session age spec to fixture time

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 18:00:54 +01:00

309 lines
8.6 KiB
TypeScript

import { RefreshCw } from "lucide-react-native";
import { useMemo } from "react";
import { Text, View, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
iconButtonChromeGlyphSize,
mutedIconColorMapping,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { StatusBadge } from "@/components/ui/status-badge";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import { UsageBalanceBar } from "./balance-bar";
import { usageCopy } from "./copy";
import { formatUsageFreshness, type UsageRefresh } from "./model";
import { useReportRefresh } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import type { UsageReport, UsageReportEntry } from "./types";
import { UsageWindowBar } from "./window-bar";
function statusText(report: UsageReport): string | null {
if (report.status === "available") return null;
return report.status === "error" ? "Error" : "Unavailable";
}
const ThemedRefreshIcon = withUnistyles(RefreshCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
export function UsageCard({
serverId,
entry,
compact = false,
}: {
serverId: string;
entry: UsageReportEntry;
compact?: boolean;
}) {
const isCompact = useIsCompactFormFactor();
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
const showsFreshnessInline = isNative || isCompact;
const usage = entry.report;
const status = statusText(usage);
const footer = entry.account.label ?? null;
const balances = usage.balances ?? [];
const details = usage.details ?? [];
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
[compact],
);
const dotStyle = useMemo(
() => [
styles.statusDot,
usage.status === "available" && styles.statusDotAvailable,
usage.status === "error" && styles.statusDotError,
],
[usage.status],
);
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}
<UsageRefreshButton
sourceLabel={entry.sourceLabel}
fetchedAt={entry.fetchedAt}
refreshState={refreshState}
onRefresh={refresh}
compact={isCompact}
/>
</View>
{usage.error ? (
<Text style={styles.error} numberOfLines={3}>
{usage.error}
</Text>
) : null}
{usage.windows.length > 0 || balances.length > 0 ? (
<View style={styles.bars}>
{usage.windows.map((window) => (
<UsageWindowBar key={window.id} window={window} />
))}
{balances.map((balance) => (
<UsageBalanceBar key={balance.id} balance={balance} />
))}
</View>
) : null}
{details.length > 0 ? (
<View style={styles.details}>
{details.map((detail) => (
<View key={detail.id} style={styles.detailRow}>
<Text style={styles.detailLabel} numberOfLines={1}>
{detail.label}
</Text>
<Text style={styles.detailValue} numberOfLines={1}>
{detail.value}
</Text>
</View>
))}
</View>
) : null}
{footer || showsFreshnessInline ? (
<View style={styles.footerRow}>
<Text style={styles.footer} numberOfLines={1}>
{footer}
</Text>
{showsFreshnessInline ? (
<UsageFreshness
fetchedAt={entry.fetchedAt}
style={styles.freshness}
testID="usage-freshness"
/>
) : null}
</View>
) : null}
{refreshState === "failed" ? (
<Text style={styles.error} testID="usage-refresh-error">
{usageCopy.refreshFailed}
</Text>
) : null}
</View>
);
}
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
function UsageRefreshButton({
sourceLabel,
fetchedAt,
refreshState,
onRefresh,
compact,
}: {
sourceLabel: string;
fetchedAt: string;
refreshState: UsageRefresh;
onRefresh: () => void;
compact: boolean;
}) {
const isPending = refreshState === "pending";
const iconSize = iconButtonChromeGlyphSize("small", compact);
const freshness = useMemo(
() => (
<UsageFreshness
fetchedAt={fetchedAt}
style={styles.tooltipText}
testID="usage-freshness-tooltip"
/>
),
[fetchedAt],
);
return (
<ToolbarButton
label={`${usageCopy.refresh} ${sourceLabel}`}
tooltip={freshness}
tooltipSide="top"
compact={compact}
disabled={isPending}
onPress={onRefresh}
style={compact ? styles.refreshButtonCompact : styles.refreshButton}
testID="usage-refresh"
>
{isPending ? (
<ThemedLoadingSpinner size={iconSize} uniProps={mutedIconColorMapping} />
) : (
<ThemedRefreshIcon size={iconSize} uniProps={mutedIconColorMapping} />
)}
</ToolbarButton>
);
}
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
function UsageFreshness({
fetchedAt,
style,
testID,
}: {
fetchedAt: string;
style: StyleProp<TextStyle>;
testID: string;
}) {
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
return (
<Text style={style} numberOfLines={1} testID={testID}>
{formatUsageFreshness(elapsed)}
</Text>
);
}
// The Refresh glyph lands on the card's right rail and the header keeps its text height;
// the button's larger hitbox overhangs both instead of pushing them.
function iconHitboxOverhang(compact: boolean) {
const overhang =
(smallIconButtonChromeFrameSize(compact) - iconButtonChromeGlyphSize("small", compact)) / 2;
return { marginRight: -overhang, marginVertical: -overhang };
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
containerPadded: {
gap: theme.spacing[4],
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
containerCompact: {
gap: theme.spacing[3],
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
name: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
headerSpacer: {
flex: 1,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: theme.colors.foregroundMuted,
},
statusDotAvailable: {
backgroundColor: theme.colors.statusSuccess,
},
statusDotError: {
backgroundColor: theme.colors.statusDanger,
},
statusLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
bars: {
gap: theme.spacing[3],
},
details: {
gap: theme.spacing[1],
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing[2],
},
detailLabel: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
detailValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
error: {
color: theme.colors.palette.red[300],
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.popoverForeground,
fontSize: theme.fontSize.sm,
},
refreshButton: iconHitboxOverhang(false),
refreshButtonCompact: iconHitboxOverhang(true),
}));