diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index 1d98f6c591..6835e01bcb 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -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(); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2f285ebcdb..1cb6f0da06 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "الآن", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 896e8ebac7..0511af34e2 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 016eb99c6e..ffe9cedc1e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index cbbf2d9ec8..6c9df18211 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 05a7aa457e..dd214ebac9 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "たった今", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d8a7491e92..d30635b2a5 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -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: "방금", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b2abd39b25..1d9ce0cb95 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index cdacf42173..766f8bdae7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "только что", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f8131c7dca..64867d7d21 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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: "刚刚", diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index def723c554..bcd33873cc 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -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>( - () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], - [usedPct, tone], - ); + const resetTime = formatResetTime(balance.resetsAt, now, t); return ( @@ -78,22 +67,21 @@ export function UsageBalanceBar({ {formatProviderUsageLabel(balance.id, balance.label, t)} - - - {amountText} - - {resetLabel ? ( - - {resetLabel} - - ) : null} - + + {amountText} + - {usedPct != null ? ( - - - - ) : null} + {usedPct != null ? : null} + ); } @@ -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, }, })); diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index b7b81f0e5a..89fb9a7ed6 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -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"); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index e7a6ba6f7b..867976a586 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -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, + 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( diff --git a/packages/app/src/usage/meter.tsx b/packages/app/src/usage/meter.tsx index 37842cafb5..4150477801 100644 --- a/packages/app/src/usage/meter.tsx +++ b/packages/app/src/usage/meter.tsx @@ -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", }, diff --git a/packages/app/src/usage/reset-line.tsx b/packages/app/src/usage/reset-line.tsx new file mode 100644 index 0000000000..8016df2926 --- /dev/null +++ b/packages/app/src/usage/reset-line.tsx @@ -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 ( + + {countdown} + {exactTime ? ( + + {`\u00a0·\u00a0${exactTime}`} + + ) : null} + + ); +} + +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 }, +})); diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 45a8a8b293..2ca99c4e29 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -192,12 +192,7 @@ function PinnedUsageLine({ {layout === "meters" ? ( - + ) : null} ({ minWidth: MIN_METER_WIDTH, marginRight: METER_GAP, }, - meter: { - height: 6, - borderRadius: 3, - }, percent: { flexShrink: 0, color: theme.colors.foreground, diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 99c1eb9f21..b837872e01 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -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 ( - + ); } - // 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 ( - {({ hovered }: { hovered?: boolean }) => ( - - )} + ); } function WindowRowContent({ - highlight, label, value, - trailing, + countdown, + exactTime, isAtRisk, percent, tone, pinnable, pinned, }: { - highlight: StyleProp; 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 ( <> - - - - - - {label} - - - {value} - {trailing ? ( - {` · ${trailing}`} - ) : null} - - - - + + + {label} + + + {value} + {pinnable ? : null} + + ); } @@ -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, }, })); diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts index 948ee8eeb3..1c063d1a27 100644 --- a/packages/app/src/utils/time.ts +++ b/packages/app/src/utils/time.ts @@ -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) {