diff --git a/docs/i18n.md b/docs/i18n.md index b3653a8b1d..0e132c89a8 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -91,3 +91,4 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t - Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch. - Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added. - Batch 5B added Korean (`ko`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `ko` and Korean regional locales. +- Batch 5C migrated provider-usage chrome across the Usage settings tab and context-meter tooltip: loading/error/empty states, refresh actions, usage/remaining labels, availability statuses, reset/run-out timing, and updated-at text. Provider names, plan labels, usage-window labels, balance/detail labels, source labels, and raw provider errors remain runtime values. diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index d29de2b33d..869a26a88c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1950,6 +1950,43 @@ export const ar: TranslationResources = { saveAccessibility: "حفظ تعليق المراجعة", }, }, + providerUsage: { + title: "استخدام الخطة", + refresh: "تحديث", + refreshing: "جارٍ التحديث...", + loading: "جارٍ تحميل بيانات الاستخدام...", + empty: "لا توجد بيانات استخدام", + errorTitle: "تعذّر تحميل بيانات الاستخدام", + hostUnavailable: "اتصل بهذا المضيف لعرض استخدام المزوّد", + hostUpgradeRequired: "حدّث المضيف لعرض استخدام المزوّد", + clientUnavailable: "اتصال المضيف غير جاهز", + retry: "حاول مرة أخرى", + tooltipLoading: "جارٍ تحميل استخدام الخطة...", + status: { + error: "خطأ", + unavailable: "غير متاح", + }, + values: { + used: "تم استخدام {{percentage}}", + remaining: "متبقٍ {{amount}}", + }, + duration: { + days: "{{value}} يوم", + hours: "{{value}} ساعة", + minutes: "{{value}} دقيقة", + }, + timing: { + resettingNow: "تتم إعادة التعيين الآن", + resetsIn: "تتم إعادة التعيين خلال {{duration}}", + runsOutNow: "ينفد الآن", + runsOutIn: "ينفد خلال {{duration}}", + justNow: "الآن", + daysAgo: "قبل {{value}} يومًا", + hoursAgo: "قبل {{value}} ساعة", + minutesAgo: "قبل {{value}} دقيقة", + updated: "تم التحديث {{relativeTime}}", + }, + }, settings: { title: "إعدادات", loading: "جارٍ تحميل الإعدادات...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1e2d4c3f10..13544c4d09 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1975,6 +1975,43 @@ export const en = { saveAccessibility: "Save review comment", }, }, + providerUsage: { + title: "Plan usage", + refresh: "Refresh", + refreshing: "Refreshing...", + loading: "Loading usage...", + empty: "No usage data", + errorTitle: "Unable to load usage", + hostUnavailable: "Connect to this host to see provider usage", + hostUpgradeRequired: "Update the host to see provider usage", + clientUnavailable: "Host connection is not ready", + retry: "Try again", + tooltipLoading: "Loading plan usage...", + status: { + error: "Error", + unavailable: "Unavailable", + }, + values: { + used: "{{percentage}} used", + remaining: "{{amount}} left", + }, + duration: { + days: "{{value}}d", + hours: "{{value}}h", + minutes: "{{value}}m", + }, + timing: { + resettingNow: "resetting now", + resetsIn: "resets {{duration}}", + runsOutNow: "runs out now", + runsOutIn: "runs out {{duration}}", + justNow: "just now", + daysAgo: "{{value}}d ago", + hoursAgo: "{{value}}h ago", + minutesAgo: "{{value}}m ago", + updated: "Updated {{relativeTime}}", + }, + }, settings: { title: "Settings", loading: "Loading settings...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index cc37432b5b..718499f853 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1998,6 +1998,43 @@ export const es: TranslationResources = { saveAccessibility: "Guardar comentario de revisión", }, }, + providerUsage: { + title: "Uso del plan", + refresh: "Actualizar", + refreshing: "Actualizando...", + loading: "Cargando uso...", + empty: "No hay datos de uso", + errorTitle: "No se pudo cargar el uso", + hostUnavailable: "Conéctate a este host para ver el uso del proveedor", + hostUpgradeRequired: "Actualiza el host para ver el uso del proveedor", + clientUnavailable: "La conexión con el host no está lista", + retry: "Intentar de nuevo", + tooltipLoading: "Cargando uso del plan...", + status: { + error: "Error", + unavailable: "No disponible", + }, + values: { + used: "Usado: {{percentage}}", + remaining: "Quedan {{amount}}", + }, + duration: { + days: "{{value}} d", + hours: "{{value}} h", + minutes: "{{value}} min", + }, + timing: { + resettingNow: "se restablece ahora", + resetsIn: "se restablece en {{duration}}", + runsOutNow: "se agota ahora", + runsOutIn: "se agota en {{duration}}", + justNow: "ahora mismo", + daysAgo: "hace {{value}} d", + hoursAgo: "hace {{value}} h", + minutesAgo: "hace {{value}} min", + updated: "Actualizado {{relativeTime}}", + }, + }, settings: { title: "Ajustes", loading: "Cargando configuración...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index aaf2608f9e..442c5c940c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2003,6 +2003,43 @@ export const fr: TranslationResources = { saveAccessibility: "Enregistrer le commentaire de l'avis", }, }, + providerUsage: { + title: "Utilisation du forfait", + refresh: "Actualiser", + refreshing: "Actualisation...", + loading: "Chargement de l’utilisation...", + empty: "Aucune donnée d’utilisation", + errorTitle: "Impossible de charger l’utilisation", + hostUnavailable: "Connectez-vous à cet hôte pour voir l’utilisation du fournisseur", + hostUpgradeRequired: "Mettez à jour l’hôte pour voir l’utilisation du fournisseur", + clientUnavailable: "La connexion à l’hôte n’est pas prête", + retry: "Réessayer", + tooltipLoading: "Chargement de l’utilisation du forfait...", + status: { + error: "Erreur", + unavailable: "Indisponible", + }, + values: { + used: "Utilisé : {{percentage}}", + remaining: "Il reste {{amount}}", + }, + duration: { + days: "{{value}} j", + hours: "{{value}} h", + minutes: "{{value}} min", + }, + timing: { + resettingNow: "réinitialisation en cours", + resetsIn: "se réinitialise dans {{duration}}", + runsOutNow: "s’épuise maintenant", + runsOutIn: "s’épuise dans {{duration}}", + justNow: "à l’instant", + daysAgo: "il y a {{value}} j", + hoursAgo: "il y a {{value}} h", + minutesAgo: "il y a {{value}} min", + updated: "Mis à jour {{relativeTime}}", + }, + }, settings: { title: "Paramètres", loading: "Chargement des paramètres...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 3491b507a2..6e449075d9 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1970,6 +1970,43 @@ export const ja: TranslationResources = { saveAccessibility: "レビューコメントを保存", }, }, + providerUsage: { + title: "プラン使用量", + refresh: "更新", + refreshing: "更新中...", + loading: "使用状況を読み込み中...", + empty: "使用状況データがありません", + errorTitle: "使用状況を読み込めません", + hostUnavailable: "このホストに接続してプロバイダーの使用状況を確認してください", + hostUpgradeRequired: "ホストを更新してプロバイダーの使用状況を確認してください", + clientUnavailable: "ホスト接続の準備ができていません", + retry: "再試行", + tooltipLoading: "プラン使用状況を読み込み中...", + status: { + error: "エラー", + unavailable: "利用不可", + }, + values: { + used: "{{percentage}} 使用済み", + remaining: "残り {{amount}}", + }, + duration: { + days: "{{value}}日", + hours: "{{value}}時間", + minutes: "{{value}}分", + }, + timing: { + resettingNow: "まもなくリセット", + resetsIn: "{{duration}}後にリセット", + runsOutNow: "まもなく上限に到達", + runsOutIn: "{{duration}}後に上限に到達", + justNow: "たった今", + daysAgo: "{{value}}日前", + hoursAgo: "{{value}}時間前", + minutesAgo: "{{value}}分前", + updated: "更新: {{relativeTime}}", + }, + }, settings: { title: "設定", loading: "設定を読み込み中...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index a08088b2e4..bae71a564a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1983,6 +1983,43 @@ export const ptBR: TranslationResources = { saveAccessibility: "Salvar comentário de revisão", }, }, + providerUsage: { + title: "Uso do plano", + refresh: "Atualizar", + refreshing: "Atualizando...", + loading: "Carregando uso...", + empty: "Sem dados de uso", + errorTitle: "Não foi possível carregar o uso", + hostUnavailable: "Conecte-se a este host para ver o uso do provedor", + hostUpgradeRequired: "Atualize o host para ver o uso do provedor", + clientUnavailable: "A conexão com o host não está pronta", + retry: "Tentar novamente", + tooltipLoading: "Carregando uso do plano...", + status: { + error: "Erro", + unavailable: "Indisponível", + }, + values: { + used: "{{percentage}} usado", + remaining: "{{amount}} restantes", + }, + duration: { + days: "{{value}} d", + hours: "{{value}} h", + minutes: "{{value}} min", + }, + timing: { + resettingNow: "redefinindo agora", + resetsIn: "redefine em {{duration}}", + runsOutNow: "esgota agora", + runsOutIn: "esgota em {{duration}}", + justNow: "agora mesmo", + daysAgo: "há {{value}} d", + hoursAgo: "há {{value}} h", + minutesAgo: "há {{value}} min", + updated: "Atualizado {{relativeTime}}", + }, + }, settings: { title: "Configurações", loading: "Carregando configurações...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4526784896..5eac9f746f 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1983,6 +1983,43 @@ export const ru: TranslationResources = { saveAccessibility: "Сохранить комментарий к ревью", }, }, + providerUsage: { + title: "Использование плана", + refresh: "Обновить", + refreshing: "Обновление...", + loading: "Загрузка данных об использовании...", + empty: "Нет данных об использовании", + errorTitle: "Не удалось загрузить данные об использовании", + hostUnavailable: "Подключитесь к этому хосту, чтобы увидеть использование провайдера", + hostUpgradeRequired: "Обновите хост, чтобы увидеть использование провайдера", + clientUnavailable: "Соединение с хостом не готово", + retry: "Повторить", + tooltipLoading: "Загрузка использования плана...", + status: { + error: "Ошибка", + unavailable: "Недоступно", + }, + values: { + used: "Использовано: {{percentage}}", + remaining: "Осталось: {{amount}}", + }, + duration: { + days: "{{value}} дн.", + hours: "{{value}} ч", + minutes: "{{value}} мин", + }, + timing: { + resettingNow: "сброс сейчас", + resetsIn: "сброс через {{duration}}", + runsOutNow: "закончится сейчас", + runsOutIn: "закончится через {{duration}}", + justNow: "только что", + daysAgo: "{{value}} дн. назад", + hoursAgo: "{{value}} ч назад", + minutesAgo: "{{value}} мин назад", + updated: "Обновлено {{relativeTime}}", + }, + }, settings: { title: "Настройки", loading: "Загрузка настроек...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d13007dbea..3f12ac2005 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1928,6 +1928,43 @@ export const zhCN: TranslationResources = { saveAccessibility: "保存 review 评论", }, }, + providerUsage: { + title: "套餐用量", + refresh: "刷新", + refreshing: "刷新中...", + loading: "正在加载用量...", + empty: "暂无用量数据", + errorTitle: "无法加载用量", + hostUnavailable: "连接到此主机以查看提供商用量", + hostUpgradeRequired: "更新主机以查看提供商用量", + clientUnavailable: "主机连接尚未就绪", + retry: "重试", + tooltipLoading: "正在加载套餐用量...", + status: { + error: "错误", + unavailable: "不可用", + }, + values: { + used: "已使用 {{percentage}}", + remaining: "剩余 {{amount}}", + }, + duration: { + days: "{{value}}天", + hours: "{{value}}小时", + minutes: "{{value}}分钟", + }, + timing: { + resettingNow: "正在重置", + resetsIn: "将在 {{duration}}后重置", + runsOutNow: "即将用尽", + runsOutIn: "将在 {{duration}}后用尽", + justNow: "刚刚", + daysAgo: "{{value}}天前", + hoursAgo: "{{value}}小时前", + minutesAgo: "{{value}}分钟前", + updated: "更新于 {{relativeTime}}", + }, + }, settings: { title: "设置", loading: "正在加载设置...", diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 751193eec0..e25448f0d3 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,4 +1,5 @@ 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 { StyleSheet } from "react-native-unistyles"; @@ -10,7 +11,11 @@ interface ResolvedBalance { usedPct: number | null; } -function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance { +function resolveBalance( + balance: UsageBalance, + t: TFunction, + locale?: string, +): ResolvedBalance { const { used, remaining, limit, unit } = balance; const format = (value: number) => formatAmount(value, unit, locale); if (limit != null && limit > 0) { @@ -20,7 +25,10 @@ function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance return { amountText: `${usedText} / ${format(limit)}`, usedPct }; } if (remaining != null) { - return { amountText: `${format(remaining)} left`, usedPct: null }; + return { + amountText: t("providerUsage.values.remaining", { amount: format(remaining) }), + usedPct: null, + }; } if (used != null) { return { amountText: format(used), usedPct: null }; @@ -42,8 +50,8 @@ function fillToneStyle(tone: UsageTone) { } export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { - const { i18n } = useTranslation(); - const { amountText, usedPct } = resolveBalance(balance, i18n.language); + const { t, i18n } = useTranslation(); + const { amountText, usedPct } = resolveBalance(balance, t, i18n.resolvedLanguage); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); @@ -58,7 +66,7 @@ export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { {balance.label} - + {amountText} {resetLabel ? {` · ${resetLabel}`} : null} @@ -88,6 +96,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, value: { + flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 88fe227ae6..cda2b9d9d4 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it } from "vitest"; -import { formatAmount } from "./format"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; +import { formatAgo, formatAmount, formatPct, formatResetLabel, formatRunsOutLabel } from "./format"; + +const NOW = Date.parse("2026-07-19T00:00:00.000Z"); describe("formatAmount", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + afterEach(async () => { + await i18n.changeLanguage("en"); + }); it("groups thousands in the app's language", () => { expect(formatAmount(12345, "credits", "en")).toBe("12,345"); expect(formatAmount(12345, "requests", "en")).toBe("12,345"); @@ -12,4 +22,18 @@ describe("formatAmount", () => { expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50"); expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); }); + + it("localizes usage timing without patching the global clock", async () => { + const twoHoursFromNow = new Date(NOW + 2 * 60 * 60 * 1000).toISOString(); + const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString(); + + expect(formatPct(7, "en")).toBe("7%"); + expect(formatResetLabel(twoHoursFromNow, NOW)).toBe("resets in 2h"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h"); + + await i18n.changeLanguage("ja"); + expect(formatResetLabel(twoHoursFromNow, NOW)).toBe("2時間後にリセット"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("2時間後に上限に到達"); + expect(formatAgo(threeDaysAgo, NOW)).toBe("3日前"); + }); }); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index 5adef2154d..c99a7a07a8 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,46 +1,139 @@ import { formatTokenCount } from "@/components/context-window-meter.utils"; +import { i18n } from "@/i18n/i18next"; import type { UsageDisplayAs } from "./preferences"; import type { UsageBalanceUnit } from "./types"; +const providerUsageLabelKeys = { + session: "providerUsage.labels.session", + five_hour: "providerUsage.labels.session", + weekly: "providerUsage.labels.weekly", + monthly: "providerUsage.labels.monthly", + code_review: "providerUsage.labels.codeReview", + credits: "providerUsage.labels.credits", + monthly_credits: "providerUsage.labels.monthlyCredits", +} as const; + +export function formatProviderUsageLabel(id: string, fallback: string): string { + const key = providerUsageLabelKeys[id as keyof typeof providerUsageLabelKeys]; + return key ? i18n.t(key) : fallback; +} + export function clampPct(value: number): number { return Math.max(0, Math.min(100, value)); } -export function formatPct(value: number): string { - return `${Math.round(clampPct(value))}%`; +export function formatPct(value: number, locale?: string): string { + return new Intl.NumberFormat(locale, { + style: "percent", + maximumFractionDigits: 0, + }).format(clampPct(value) / 100); } -/** "31%" of the window used, or "69% left" of it. */ -export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string { - return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`; +/** A localized percentage that says whether it is used or remaining. */ +export function formatDisplayPct(value: number, displayAs: UsageDisplayAs, locale?: string): string { + const percentage = formatPct(value, locale); + return displayAs === "used" + ? i18n.t("providerUsage.values.used", { percentage }) + : i18n.t("providerUsage.values.remaining", { amount: percentage }); } -function relativeDuration(iso: string): string | null { - const diffMs = new Date(iso).getTime() - Date.now(); +type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number }; + +function formatCount(value: number): string { + return new Intl.NumberFormat(i18n.resolvedLanguage).format(value); +} + +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 "now"; + 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 `${diffDays}d`; - if (diffHours > 0) return `${diffHours}h`; - return `${diffMinutes}m`; + if (diffDays > 0) return { unit: "days", count: diffDays }; + if (diffHours > 0) return { unit: "hours", count: diffHours }; + return { unit: "minutes", count: diffMinutes }; } -export function formatResetLabel(iso: string | null | undefined): string | null { +function formatRelativeDuration(duration: RelativeDuration): string | null { + switch (duration.unit) { + case "now": + return null; + case "days": + return i18n.t("providerUsage.duration.days", { + count: duration.count, + value: formatCount(duration.count), + }); + case "hours": + return i18n.t("providerUsage.duration.hours", { + count: duration.count, + value: formatCount(duration.count), + }); + case "minutes": + return i18n.t("providerUsage.duration.minutes", { + count: duration.count, + value: formatCount(duration.count), + }); + } +} + +export function formatResetLabel(iso: string | null | undefined, now = Date.now()): string | null { if (!iso) return null; - const rel = relativeDuration(iso); + const rel = relativeDuration(iso, now); if (!rel) return null; - return rel === "now" ? "resetting now" : `resets ${rel}`; + if (rel.unit === "now") return i18n.t("providerUsage.timing.resettingNow"); + const duration = formatRelativeDuration(rel); + return duration ? i18n.t("providerUsage.timing.resetsIn", { duration }) : null; +} + +export function formatRunsOutLabel( + iso: string | null | undefined, + now = Date.now(), +): string | null { + if (!iso) return null; + const rel = relativeDuration(iso, now); + if (!rel) return null; + if (rel.unit === "now") return i18n.t("providerUsage.timing.runsOutNow"); + const duration = formatRelativeDuration(rel); + return duration ? i18n.t("providerUsage.timing.runsOutIn", { duration }) : null; +} + +export function formatAgo(iso: string | null | undefined, now = Date.now()): string | null { + if (!iso) return null; + const diffMs = now - new Date(iso).getTime(); + if (!Number.isFinite(diffMs)) return null; + if (diffMs < 60_000) return i18n.t("providerUsage.timing.justNow"); + const diffMinutes = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + if (diffDays > 0) { + return i18n.t("providerUsage.timing.daysAgo", { + count: diffDays, + value: formatCount(diffDays), + }); + } + if (diffHours > 0) { + return i18n.t("providerUsage.timing.hoursAgo", { + count: diffHours, + value: formatCount(diffHours), + }); + } + return i18n.t("providerUsage.timing.minutesAgo", { + count: diffMinutes, + value: formatCount(diffMinutes), + }); } /** A balance amount as the app's language writes it: "$1,234.50", "12,345". */ -export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string { +export function formatAmount(value: number, unit: UsageBalanceUnit, locale?: string): string { switch (unit) { case "usd": return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value); case "tokens": - return formatTokenCount(value); + return new Intl.NumberFormat(locale, { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); default: return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value); } diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 8b668eab00..35525e1c36 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,10 +1,16 @@ 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 { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { usageCopy } from "./copy"; -import { formatDisplayPct, formatResetLabel } from "./format"; +import { + formatDisplayPct, + formatProviderUsageLabel, + formatResetLabel, + formatRunsOutLabel, +} from "./format"; import { UsageMeter } from "./meter"; import { displayPercent, usageWindowRowLabel } from "./model"; import type { UsageDisplayAs } from "./preferences"; @@ -34,15 +40,17 @@ export function UsageWindowBar({ pinLabel: string; pinTestID: string; }) { + const { i18n } = useTranslation(); const shownPct = displayPercent(window, displayAs); const tone = windowTone(window); const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; const trailing = isAtRisk - ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() + ? formatRunsOutLabel(window.runsOutAt) : formatResetLabel(window.resetsAt); - const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; + const value = + shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); // The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same @@ -60,7 +68,7 @@ export function UsageWindowBar({ {({ hovered }: { hovered?: boolean }) => ( {label} - + {value} {trailing ? ( {` · ${trailing}`} @@ -186,6 +194,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, value: { + flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium,