feat(usage): show when each limit resets, and make usage rows easier to read (#8162)

The reset countdown floored to one unit, so a weekly window read "resets 2d"
from 2d 23h down to 2d 0h and looked stuck for a day; the session window had
the same problem with hours. Countdowns now use two units ("2d 23h", "4h 59m"),
and every row also shows the clock time it resets ("7:20 AM", "Sat 8:00 AM",
"Oct 31, 3:00 PM"), so users can plan work around a reset without hovering.

Rows now put the window name and percent on one line, the bar under it, and the
reset on its own full-width line, so the exact time fits at phone width in
longer languages. The pinned-row background is gone; the pin glyph, drawn in
the foreground colour when pinned, is the only pinned signal. Bars are 6px.
This commit is contained in:
Mohamed Boudra
2026-10-29 11:24:34 +07:00
committed by GitHub
parent 304cbde651
commit e12feb633f
18 changed files with 291 additions and 213 deletions
+4 -4
View File
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets (28|29)m$/,
/^resets in (28|29)m\s·\s/,
);
} finally {
await login.cleanup();
+3
View File
@@ -2157,6 +2157,7 @@ export const ar: TranslationResources = {
minutes_few: "{{value}} دقائق",
minutes_many: "{{value}} دقيقة",
minutes_other: "{{value}} دقيقة",
pair: "{{first}} و{{second}}",
},
timing: {
lastUpdated: "آخر تحديث {{relativeTime}}",
@@ -2164,6 +2165,8 @@ export const ar: TranslationResources = {
resetTimePassed: "مضى وقت إعادة التعيين",
resettingNow: "تتم إعادة التعيين الآن",
resetsIn: "تتم إعادة التعيين خلال {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}، {{time}}",
runsOutNow: "ينفد الآن",
runsOutIn: "ينفد خلال {{duration}}",
justNow: "الآن",
+5 -2
View File
@@ -2164,15 +2164,18 @@ export const en = {
days: "{{value}}d",
hours: "{{value}}h",
minutes: "{{value}}m",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Last updated {{relativeTime}}",
noSuccessfulUpdate: "No successful update",
resetTimePassed: "Reset time passed",
resettingNow: "resetting now",
resetsIn: "resets {{duration}}",
resetsIn: "resets in {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "runs out now",
runsOutIn: "runs out {{duration}}",
runsOutIn: "runs out in {{duration}}",
justNow: "just now",
daysAgo: "{{value}}d ago",
hoursAgo: "{{value}}h ago",
+3
View File
@@ -2187,6 +2187,7 @@ export const es: TranslationResources = {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Última actualización {{relativeTime}}",
@@ -2194,6 +2195,8 @@ export const es: TranslationResources = {
resetTimePassed: "Pasó la hora de restablecimiento",
resettingNow: "se restablece ahora",
resetsIn: "se restablece en {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "se agota ahora",
runsOutIn: "se agota en {{duration}}",
justNow: "ahora mismo",
+3
View File
@@ -2187,6 +2187,7 @@ export const fr: TranslationResources = {
days: "{{value}} j",
hours: "{{value}} h",
minutes: "{{value}} min",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Dernière mise à jour {{relativeTime}}",
@@ -2194,6 +2195,8 @@ export const fr: TranslationResources = {
resetTimePassed: "Heure de réinitialisation dépassée",
resettingNow: "réinitialisation en cours",
resetsIn: "se réinitialise dans {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "s’épuise maintenant",
runsOutIn: "s’épuise dans {{duration}}",
justNow: "à l’instant",
+3
View File
@@ -2160,6 +2160,7 @@ export const ja: TranslationResources = {
days: "{{value}}日",
hours: "{{value}}時間",
minutes: "{{value}}分",
pair: "{{first}}{{second}}",
},
timing: {
lastUpdated: "最終更新: {{relativeTime}}",
@@ -2167,6 +2168,8 @@ export const ja: TranslationResources = {
resetTimePassed: "リセット時刻を過ぎています",
resettingNow: "まもなくリセット",
resetsIn: "{{duration}}後にリセット",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "まもなく上限に到達",
runsOutIn: "{{duration}}後に上限に到達",
justNow: "たった今",
+3
View File
@@ -2149,6 +2149,7 @@ export const ko: TranslationResources = {
days: "{{value}}일",
hours: "{{value}}시간",
minutes: "{{value}}분",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "마지막 업데이트: {{relativeTime}}",
@@ -2156,6 +2157,8 @@ export const ko: TranslationResources = {
resetTimePassed: "초기화 시간이 지났습니다",
resettingNow: "지금 재설정 중",
resetsIn: "{{duration}} 후 재설정",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "지금 한도에 도달",
runsOutIn: "{{duration}} 후 한도에 도달",
justNow: "방금",
+3
View File
@@ -2172,6 +2172,7 @@ export const ptBR: TranslationResources = {
days: "{{value}} d",
hours: "{{value}} h",
minutes: "{{value}} min",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Última atualização {{relativeTime}}",
@@ -2179,6 +2180,8 @@ export const ptBR: TranslationResources = {
resetTimePassed: "O horário de redefinição passou",
resettingNow: "redefinindo agora",
resetsIn: "redefine em {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "esgota agora",
runsOutIn: "esgota em {{duration}}",
justNow: "agora mesmo",
+3
View File
@@ -2172,6 +2172,7 @@ export const ru: TranslationResources = {
days: "{{value}} дн.",
hours: "{{value}} ч",
minutes: "{{value}} мин",
pair: "{{first}} {{second}}",
},
timing: {
lastUpdated: "Последнее обновление {{relativeTime}}",
@@ -2179,6 +2180,8 @@ export const ru: TranslationResources = {
resetTimePassed: "Время сброса прошло",
resettingNow: "сброс сейчас",
resetsIn: "сброс через {{duration}}",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}}, {{time}}",
runsOutNow: "закончится сейчас",
runsOutIn: "закончится через {{duration}}",
justNow: "только что",
+3
View File
@@ -2117,6 +2117,7 @@ export const zhCN: TranslationResources = {
days: "{{value}}天",
hours: "{{value}}小时",
minutes: "{{value}}分钟",
pair: "{{first}}{{second}}",
},
timing: {
lastUpdated: "上次更新于 {{relativeTime}}",
@@ -2124,6 +2125,8 @@ export const zhCN: TranslationResources = {
resetTimePassed: "重置时间已过",
resettingNow: "正在重置",
resetsIn: "将在 {{duration}}后重置",
resetsAtWeekday: "{{weekday}} {{time}}",
resetsAtDate: "{{date}} {{time}}",
runsOutNow: "即将用尽",
runsOutIn: "将在 {{duration}}后用尽",
justNow: "刚刚",
+28 -71
View File
@@ -1,10 +1,16 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format";
import type { UsageBalance, UsageTone } from "./types";
import {
formatAmount,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
} from "./format";
import { UsageMeter } from "./meter";
import { UsageResetLine } from "./reset-line";
import type { UsageBalance } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick";
interface ResolvedBalance {
@@ -41,19 +47,6 @@ function resolveBalance({
return { amountText: "—", usedPct: null };
}
function fillToneStyle(tone: UsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
export function UsageBalanceBar({
balance,
retained = false,
@@ -66,11 +59,7 @@ export function UsageBalanceBar({
const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage });
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
[usedPct, tone],
);
const resetTime = formatResetTime(balance.resetsAt, now, t);
return (
<View style={styles.container}>
@@ -78,22 +67,21 @@ export function UsageBalanceBar({
<Text style={styles.label} numberOfLines={1}>
{formatProviderUsageLabel(balance.id, balance.label, t)}
</Text>
<View style={styles.value} testID={`provider-usage-balance-${balance.id}-value`}>
<Text style={styles.amount} numberOfLines={1} ellipsizeMode="tail">
{amountText}
</Text>
{resetLabel ? (
<Text style={styles.reset} testID={`provider-usage-balance-${balance.id}-reset`}>
{resetLabel}
</Text>
) : null}
</View>
<Text
style={styles.amount}
numberOfLines={1}
ellipsizeMode="tail"
testID={`provider-usage-balance-${balance.id}-value`}
>
{amountText}
</Text>
</View>
{usedPct != null ? (
<View style={styles.track}>
<View style={fillStyle} />
</View>
) : null}
{usedPct != null ? <UsageMeter percent={usedPct} tone={tone} /> : null}
<UsageResetLine
countdown={resetLabel}
exactTime={resetTime}
testID={`provider-usage-balance-${balance.id}-reset`}
/>
</View>
);
}
@@ -110,44 +98,13 @@ const styles = StyleSheet.create((theme) => ({
},
label: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
alignItems: "flex-end",
},
amount: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
reset: {
color: theme.colors.foregroundMuted,
amount: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
textAlign: "right",
},
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
fill: {
height: 4,
borderRadius: 2,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
},
}));
+67 -2
View File
@@ -7,6 +7,7 @@ import {
formatPct,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
formatRunsOutLabel,
} from "./format";
@@ -36,8 +37,8 @@ describe("formatAmount", () => {
const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString();
expect(formatPct(7, "en")).toBe("7%");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets in 2h");
expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h");
await i18n.changeLanguage("ja");
expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット");
@@ -94,6 +95,70 @@ describe("formatAmount", () => {
});
});
describe("reset countdown", () => {
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const countdown = (ms: number) => formatResetLabel(new Date(NOW + ms).toISOString(), false, NOW);
beforeEach(async () => {
await i18n.changeLanguage("en");
});
it("keeps counting down inside a day instead of sitting on the rounded-down day", () => {
expect(countdown(2 * DAY + 23 * HOUR + 10 * MINUTE)).toBe("resets in 2d 23h");
expect(countdown(2 * DAY + 22 * HOUR + 10 * MINUTE)).toBe("resets in 2d 22h");
});
it("says two units, dropping a unit that is zero", () => {
expect(countdown(DAY + 30 * MINUTE)).toBe("resets in 1d");
expect(countdown(4 * HOUR + 59 * MINUTE)).toBe("resets in 4h 59m");
expect(countdown(HOUR)).toBe("resets in 1h");
expect(countdown(37 * MINUTE)).toBe("resets in 37m");
expect(countdown(59 * 1000)).toBe("resets in 0m");
});
it("joins the at-risk countdown the same way", () => {
const iso = new Date(NOW + 3 * HOUR + 5 * MINUTE).toISOString();
expect(formatRunsOutLabel(iso, NOW)).toBe("runs out in 3h 5m");
});
});
describe("exact reset time", () => {
// Local-time dates, so the thresholds hold in any timezone the tests run in.
const now = new Date(2026, 9, 29, 12, 0).getTime();
const at = (...args: [number, number, number, number, number]) =>
formatResetTime(new Date(...args).toISOString(), now)?.replace(/\s/g, " ");
beforeEach(async () => {
await i18n.changeLanguage("en");
});
it("shows only the time within 24 hours", () => {
expect(at(2026, 9, 29, 15, 0)).toMatch(/^(3:00 PM|15:00)$/);
expect(at(2026, 9, 30, 11, 59)).toMatch(/^(11:59 AM|11:59)$/);
});
it("adds the weekday within 7 days", () => {
expect(at(2026, 9, 30, 12, 0)).toMatch(/^Fri (12:00 PM|12:00)$/);
expect(at(2026, 10, 5, 11, 59)).toMatch(/^Thu (11:59 AM|11:59)$/);
});
it("adds the date after 7 days", () => {
expect(at(2026, 10, 5, 12, 0)).toMatch(/^Nov 5, (12:00 PM|12:00)$/);
});
it("names the weekday in the app language", async () => {
await i18n.changeLanguage("fr");
expect(at(2026, 9, 31, 15, 0)).toMatch(/^sam\.? /);
});
it("says nothing for a time that has passed", () => {
expect(formatResetTime(new Date(now - 1).toISOString(), now)).toBeNull();
expect(formatResetTime(null, now)).toBeNull();
});
});
it("retained reset times that have passed do not claim the quota is resetting", () => {
const past = "2000-01-01T00:00:00.000Z";
expect(formatResetLabel(past, true)).toBe("Reset time passed");
+68 -35
View File
@@ -1,5 +1,6 @@
import type { TFunction } from "i18next";
import { i18n } from "@/i18n/i18next";
import { formatTimeOfDay } from "@/utils/time";
import type { UsageDisplayAs } from "./preferences";
import type { UsageBalanceUnit } from "./types";
@@ -50,7 +51,17 @@ export function formatDisplayPct(
: t("providerUsage.values.remaining", { amount: percentage });
}
type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number };
type DurationUnit = "minutes" | "hours" | "days";
interface DurationPart {
unit: DurationUnit;
count: number;
}
/** Two units, floored, so a countdown keeps moving: "2d 23h", "4h 59m", "37m". */
type RelativeDuration = { kind: "now" } | { kind: "remaining"; parts: DurationPart[] };
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
function formatCount(value: number): string {
return new Intl.NumberFormat(i18n.resolvedLanguage).format(value);
@@ -59,35 +70,34 @@ function formatCount(value: number): string {
function relativeDuration(iso: string, now: number): RelativeDuration | null {
const diffMs = new Date(iso).getTime() - now;
if (!Number.isFinite(diffMs)) return null;
if (diffMs <= 0) return { unit: "now" };
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return { unit: "days", count: diffDays };
if (diffHours > 0) return { unit: "hours", count: diffHours };
return { unit: "minutes", count: diffMinutes };
if (diffMs <= 0) return { kind: "now" };
const days = Math.floor(diffMs / DAY_MS);
const hours = Math.floor((diffMs % DAY_MS) / HOUR_MS);
const minutes = Math.floor((diffMs % HOUR_MS) / MINUTE_MS);
if (days > 0) return remaining({ unit: "days", count: days }, { unit: "hours", count: hours });
if (hours > 0)
return remaining({ unit: "hours", count: hours }, { unit: "minutes", count: minutes });
return remaining({ unit: "minutes", count: minutes });
}
function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null {
switch (duration.unit) {
case "now":
return null;
case "days":
return t("providerUsage.duration.days", {
count: duration.count,
value: formatCount(duration.count),
});
case "hours":
return t("providerUsage.duration.hours", {
count: duration.count,
value: formatCount(duration.count),
});
case "minutes":
return t("providerUsage.duration.minutes", {
count: duration.count,
value: formatCount(duration.count),
});
}
/** The major unit, plus the minor one unless it is zero. */
function remaining(major: DurationPart, minor?: DurationPart): RelativeDuration {
return { kind: "remaining", parts: minor && minor.count > 0 ? [major, minor] : [major] };
}
function formatDurationPart(part: DurationPart, t: TFunction): string {
return t(`providerUsage.duration.${part.unit}`, {
count: part.count,
value: formatCount(part.count),
});
}
function formatRelativeDuration(
duration: Extract<RelativeDuration, { kind: "remaining" }>,
t: TFunction,
): string {
const [first, second] = duration.parts.map((part) => formatDurationPart(part, t));
return second ? t("providerUsage.duration.pair", { first, second }) : first;
}
export function formatResetLabel(
@@ -99,10 +109,34 @@ export function formatResetLabel(
if (!iso) return null;
const rel = relativeDuration(iso, now);
if (!rel) return null;
if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed");
if (rel.unit === "now") return t("providerUsage.timing.resettingNow");
const duration = formatRelativeDuration(rel, t);
return duration ? t("providerUsage.timing.resetsIn", { duration }) : null;
if (rel.kind === "now" && retained) return t("providerUsage.timing.resetTimePassed");
if (rel.kind === "now") return t("providerUsage.timing.resettingNow");
return t("providerUsage.timing.resetsIn", { duration: formatRelativeDuration(rel, t) });
}
/**
* When a window resets on the clock: "3:00 PM" within a day, "Fri 3:00 PM" within a week,
* "Oct 31, 3:00 PM" after that. Names follow the app language; 12/24h follows the device.
* Nothing for a time that has passed, where the countdown already says so.
*/
export function formatResetTime(
iso: string | null | undefined,
now = Date.now(),
t: TFunction = i18n.t,
): string | null {
if (!iso) return null;
const at = new Date(iso);
const diffMs = at.getTime() - now;
if (!Number.isFinite(diffMs) || diffMs <= 0) return null;
const time = formatTimeOfDay(at);
const locale = i18n.resolvedLanguage;
if (diffMs < DAY_MS) return time;
if (diffMs < 7 * DAY_MS) {
const weekday = at.toLocaleDateString(locale, { weekday: "short" });
return t("providerUsage.timing.resetsAtWeekday", { weekday, time });
}
const date = at.toLocaleDateString(locale, { month: "short", day: "numeric" });
return t("providerUsage.timing.resetsAtDate", { date, time });
}
export function formatRunsOutLabel(
@@ -113,9 +147,8 @@ export function formatRunsOutLabel(
if (!iso) return null;
const rel = relativeDuration(iso, now);
if (!rel) return null;
if (rel.unit === "now") return t("providerUsage.timing.runsOutNow");
const duration = formatRelativeDuration(rel, t);
return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null;
if (rel.kind === "now") return t("providerUsage.timing.runsOutNow");
return t("providerUsage.timing.runsOutIn", { duration: formatRelativeDuration(rel, t) });
}
export function formatAgo(
+2 -2
View File
@@ -46,8 +46,8 @@ export function UsageMeter({
const styles = StyleSheet.create((theme) => ({
track: {
height: 4,
borderRadius: 2,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
+48
View File
@@ -0,0 +1,48 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* The reset data of a window or balance: the countdown first, then the exact time. When the line
* is too narrow the exact time gives way (truncates) and the countdown stays whole.
*/
export function UsageResetLine({
countdown,
exactTime,
atRisk = false,
testID,
}: {
countdown: string | null;
exactTime: string | null;
/** The countdown is a runs-out warning rather than a reset. */
atRisk?: boolean;
testID?: string;
}) {
if (!countdown) return null;
return (
<View style={styles.line} testID={testID}>
<Text style={atRisk ? styles.atRisk : styles.text}>{countdown}</Text>
{exactTime ? (
<Text style={[styles.text, styles.exactTime]} numberOfLines={1} ellipsizeMode="tail">
{`\u00a0·\u00a0${exactTime}`}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
line: { flexDirection: "row", alignItems: "center" },
text: {
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
atRisk: {
flexShrink: 0,
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
exactTime: { flexShrink: 1 },
}));
+1 -10
View File
@@ -192,12 +192,7 @@ function PinnedUsageLine({
<Fragment key={window.key}>
{layout === "meters" ? (
<View style={[styles.meterSlot, leadStyle]}>
<UsageMeter
percent={window.percent}
tone={window.tone}
quiet
style={styles.meter}
/>
<UsageMeter percent={window.percent} tone={window.tone} quiet />
</View>
) : null}
<Text
@@ -269,10 +264,6 @@ const styles = StyleSheet.create((theme) => ({
minWidth: MIN_METER_WIDTH,
marginRight: METER_GAP,
},
meter: {
height: 6,
borderRadius: 3,
},
percent: {
flexShrink: 0,
color: theme.colors.foreground,
+38 -86
View File
@@ -1,29 +1,24 @@
import { Pin } from "lucide-react-native";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
formatDisplayPct,
formatProviderUsageLabel,
formatResetLabel,
formatResetTime,
formatRunsOutLabel,
} from "./format";
import { UsageMeter } from "./meter";
import { displayPercent, usageWindowRowLabel } from "./model";
import { UsageResetLine } from "./reset-line";
import type { UsageDisplayAs } from "./preferences";
import { windowTone } from "./tone";
import type { UsageTone, UsageWindow } from "./types";
import { useRelativeTimeTick } from "./use-relative-time-tick";
// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength,
// so a hover never reads as the selection. Pinned rows do not react to hover.
function highlightStyle(pinned: boolean, hovered: boolean) {
if (pinned) return styles.highlightPinned;
return hovered ? styles.highlightHovered : styles.highlightNone;
}
export function UsageWindowBar({
window,
displayAs,
@@ -54,9 +49,12 @@ export function UsageWindowBar({
window.runsOutAt != null &&
window.shortfallPct != null &&
(!retained || Date.parse(window.runsOutAt) > now);
const trailing = isAtRisk
const countdown = isAtRisk
? formatRunsOutLabel(window.runsOutAt, now, t)
: formatResetLabel(window.resetsAt, retained, now, t);
// The exact time belongs to the reset; beside a runs-out warning it would read as the moment
// the quota runs out.
const exactTime = isAtRisk ? null : formatResetTime(window.resetsAt, now, t);
const value =
shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—";
@@ -64,60 +62,57 @@ export function UsageWindowBar({
const content = {
label: formatProviderUsageLabel(window.id, window.label, t),
value,
trailing,
countdown,
exactTime,
isAtRisk,
percent: shownPct ?? 0,
tone,
pinned,
};
// Same padding as the pinnable row, so bars line up in both modes.
if (!pinnable) {
return (
<View style={styles.row}>
<WindowRowContent {...content} highlight={styles.highlightNone} pinnable={false} />
<WindowRowContent {...content} pinnable={false} />
</View>
);
}
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same
// padding so toggling only changes the background.
// The whole row pins the window to the sidebar Usage item; the pin glyph is the only sign of it.
return (
<Pressable
onPress={onTogglePin}
accessibilityRole="checkbox"
accessibilityLabel={usageWindowRowLabel({ pinLabel, value, trailing })}
accessibilityLabel={usageWindowRowLabel({
pinLabel,
value,
trailing: [countdown, exactTime].filter(Boolean).join(" · "),
})}
accessibilityState={accessibilityState}
aria-checked={pinned}
style={styles.row}
testID={pinTestID}
>
{({ hovered }: { hovered?: boolean }) => (
<WindowRowContent
{...content}
highlight={highlightStyle(pinned, Boolean(hovered))}
pinnable
/>
)}
<WindowRowContent {...content} pinnable />
</Pressable>
);
}
function WindowRowContent({
highlight,
label,
value,
trailing,
countdown,
exactTime,
isAtRisk,
percent,
tone,
pinnable,
pinned,
}: {
highlight: StyleProp<ViewStyle>;
label: string;
value: string;
trailing: string | null | undefined;
countdown: string | null;
exactTime: string | null;
isAtRisk: boolean;
percent: number;
tone: UsageTone;
@@ -126,24 +121,17 @@ function WindowRowContent({
}) {
return (
<>
<View style={highlight} pointerEvents="none" />
<View style={styles.contentRow}>
<View style={styles.windowContent}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value} numberOfLines={1} ellipsizeMode="tail">
{value}
{trailing ? (
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
) : null}
</Text>
</View>
<UsageMeter percent={percent} tone={tone} />
</View>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{label}
</Text>
<Text style={styles.value} numberOfLines={1}>
{value}
</Text>
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
</View>
<UsageMeter percent={percent} tone={tone} />
<UsageResetLine countdown={countdown} exactTime={exactTime} atRisk={isAtRisk} />
</>
);
}
@@ -153,9 +141,9 @@ const ThemedPin = withUnistyles(Pin);
function UsagePinGlyph({ pinned }: { pinned: boolean }) {
const { t } = useTranslation();
const iconMapping = useMemo(
() => (theme: { colors: { foregroundMuted: string } }) => ({
color: theme.colors.foregroundMuted,
fill: pinned ? theme.colors.foregroundMuted : "none",
() => (theme: { colors: { foreground: string; foregroundMuted: string } }) => ({
color: pinned ? theme.colors.foreground : theme.colors.foregroundMuted,
fill: pinned ? theme.colors.foreground : "none",
}),
[pinned],
);
@@ -179,59 +167,23 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) {
}
const styles = StyleSheet.create((theme) => ({
contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
windowContent: { flex: 1, gap: 3 },
row: { gap: 3, paddingVertical: theme.spacing[1.5] },
pin: { width: 12, alignItems: "center" },
tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
row: {
gap: 3,
// The highlight bleeds into the card padding so the label and bar stay on the card's rail.
marginHorizontal: -theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
// Its own stacking context, so the highlight layer paints above the card and below the text.
zIndex: 0,
},
highlightNone: {
display: "none",
},
highlightPinned: {
...StyleSheet.absoluteFill,
zIndex: -1,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
// The pinned surface at half strength: a separate layer, so the text keeps full opacity.
highlightHovered: {
...StyleSheet.absoluteFill,
zIndex: -1,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
opacity: theme.opacity[50],
},
labelRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing[2],
},
label: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
value: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
reset: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
atRisk: {
color: theme.colors.statusDanger,
fontWeight: theme.fontWeight.normal,
value: {
flexShrink: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
}));
+6 -1
View File
@@ -125,6 +125,11 @@ function getTimeFormatter(): Intl.DateTimeFormat {
return cachedTimeFormatter;
}
/** A clock time in the device's 12h/24h preference: "3:00 PM" or "15:00". */
export function formatTimeOfDay(date: Date): string {
return getTimeFormatter().format(date);
}
/**
* Format a chat-message timestamp for hover-revealed UI.
* - Same day: "10:11 PM" or "22:11" depending on user preference
@@ -132,7 +137,7 @@ function getTimeFormatter(): Intl.DateTimeFormat {
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
*/
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
const time = getTimeFormatter().format(date);
const time = formatTimeOfDay(date);
const daysAgo = localCalendarDaysBetween(date, now);
if (daysAgo === 0) {