From d60c35bff2acfcdf4deb741757bb4494f4d13935 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 18 Oct 2026 05:24:40 +0900 Subject: [PATCH] fix(app): complete provider usage localization --- packages/app/src/i18n/resources/ar.ts | 4 +-- packages/app/src/i18n/resources/en.ts | 4 +-- packages/app/src/i18n/resources/es.ts | 4 +-- packages/app/src/i18n/resources/fr.ts | 4 +-- packages/app/src/i18n/resources/ja.ts | 4 +-- packages/app/src/i18n/resources/ko.ts | 4 +-- packages/app/src/i18n/resources/pt-BR.ts | 4 +-- packages/app/src/i18n/resources/ru.ts | 4 +-- packages/app/src/i18n/resources/zh-CN.ts | 4 +-- packages/app/src/usage/balance-bar.tsx | 14 ++++++-- packages/app/src/usage/card.tsx | 13 +++++--- packages/app/src/usage/copy.ts | 9 +++--- packages/app/src/usage/format.test.ts | 6 ++++ packages/app/src/usage/format.ts | 11 +++++++ packages/app/src/usage/model.test.ts | 32 ++++++++++++------- packages/app/src/usage/model.ts | 6 ---- .../app/src/usage/use-relative-time-tick.ts | 2 +- packages/app/src/usage/window-bar.tsx | 2 +- 18 files changed, 82 insertions(+), 49 deletions(-) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2134f527da..b416d82c9a 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2050,8 +2050,8 @@ export const ar: TranslationResources = { loading: "جارٍ تحميل بيانات الاستخدام...", empty: "لا توجد بيانات استخدام", errorTitle: "تعذّر تحميل بيانات الاستخدام", - hostUnavailable: "اتصل بهذا المضيف لعرض استخدام المزوّد", - hostUpgradeRequired: "حدّث المضيف لعرض استخدام المزوّد", + hostUnavailable: "اتصل بـ{{host}} لعرض استخدام المزوّد", + hostUpgradeRequired: "حدّث {{host}} لعرض استخدام المزوّد", clientUnavailable: "اتصال المضيف غير جاهز", retry: "حاول مرة أخرى", tooltipLoading: "جارٍ تحميل استخدام الخطة...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1ef0fb3c27..3126d3e334 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2075,8 +2075,8 @@ export const en = { 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", + hostUnavailable: "Connect to {{host}} to see provider usage", + hostUpgradeRequired: "Update {{host}} to see provider usage", clientUnavailable: "Host connection is not ready", retry: "Try again", tooltipLoading: "Loading plan usage...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 76d0e68b4a..86c9a8c587 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2098,8 +2098,8 @@ export const es: TranslationResources = { 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", + hostUnavailable: "Conéctate a {{host}} para ver el uso del proveedor", + hostUpgradeRequired: "Actualiza {{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...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index dbd03bf29b..6ef88c10ab 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2097,8 +2097,8 @@ export const fr: TranslationResources = { 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", + hostUnavailable: "Connectez-vous à {{host}} pour voir l’utilisation du fournisseur", + hostUpgradeRequired: "Mettez à jour {{host}} 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...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index b6abe4329b..62a5fb928c 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2071,8 +2071,8 @@ export const ja: TranslationResources = { loading: "使用状況を読み込み中...", empty: "使用状況データがありません", errorTitle: "使用状況を読み込めません", - hostUnavailable: "このホストに接続してプロバイダーの使用状況を確認してください", - hostUpgradeRequired: "ホストを更新してプロバイダーの使用状況を確認してください", + hostUnavailable: "{{host}} に接続してプロバイダーの使用状況を確認してください", + hostUpgradeRequired: "{{host}} を更新してプロバイダーの使用状況を確認してください", clientUnavailable: "ホスト接続の準備ができていません", retry: "再試行", tooltipLoading: "プラン使用状況を読み込み中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index f5f6515131..7e82cf8611 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2060,8 +2060,8 @@ export const ko: TranslationResources = { loading: "사용량 불러오는 중...", empty: "사용량 데이터가 없습니다", errorTitle: "사용량을 불러올 수 없습니다", - hostUnavailable: "이 호스트에 연결하여 제공업체 사용량을 확인하세요", - hostUpgradeRequired: "호스트를 업데이트하여 제공업체 사용량을 확인하세요", + hostUnavailable: "{{host}}에 연결하여 제공업체 사용량을 확인하세요", + hostUpgradeRequired: "{{host}}를 업데이트하여 제공업체 사용량을 확인하세요", clientUnavailable: "호스트 연결이 준비되지 않았습니다", retry: "다시 시도", tooltipLoading: "플랜 사용량 불러오는 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index d5dbcde0d5..1413c8f0f7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2083,8 +2083,8 @@ export const ptBR: TranslationResources = { 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", + hostUnavailable: "Conecte-se a {{host}} para ver o uso do provedor", + hostUpgradeRequired: "Atualize {{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...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4281f61ddd..dd4cbcecd2 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2083,8 +2083,8 @@ export const ru: TranslationResources = { loading: "Загрузка данных об использовании...", empty: "Нет данных об использовании", errorTitle: "Не удалось загрузить данные об использовании", - hostUnavailable: "Подключитесь к этому хосту, чтобы увидеть использование провайдера", - hostUpgradeRequired: "Обновите хост, чтобы увидеть использование провайдера", + hostUnavailable: "Подключитесь к {{host}}, чтобы увидеть использование провайдера", + hostUpgradeRequired: "Обновите {{host}}, чтобы увидеть использование провайдера", clientUnavailable: "Соединение с хостом не готово", retry: "Повторить", tooltipLoading: "Загрузка использования плана...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 75600fd8ad..c4b7d7d89a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2028,8 +2028,8 @@ export const zhCN: TranslationResources = { loading: "正在加载用量...", empty: "暂无用量数据", errorTitle: "无法加载用量", - hostUnavailable: "连接到此主机以查看提供商用量", - hostUpgradeRequired: "更新主机以查看提供商用量", + hostUnavailable: "连接到 {{host}} 以查看提供商用量", + hostUpgradeRequired: "更新 {{host}} 以查看提供商用量", clientUnavailable: "主机连接尚未就绪", retry: "重试", tooltipLoading: "正在加载套餐用量...", diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index c0f1acb160..3ed672aae0 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -12,7 +12,15 @@ interface ResolvedBalance { usedPct: number | null; } -function resolveBalance(balance: UsageBalance, t: TFunction, locale?: string): ResolvedBalance { +function resolveBalance({ + balance, + t, + locale, +}: { + 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) { @@ -48,8 +56,8 @@ function fillToneStyle(tone: UsageTone) { export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { const { t, i18n } = useTranslation(); - useRelativeTimeTick(balance.resetsAt != null); - const { amountText, usedPct } = resolveBalance(balance, t, i18n.resolvedLanguage); + useRelativeTimeTick({ enabled: balance.resetsAt != null }); + const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage }); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index e6f9199394..abb22edf88 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -1,5 +1,6 @@ import { RotateCw } from "lucide-react-native"; import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Text, View, type StyleProp, type TextStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { @@ -12,16 +13,17 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane- 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 { i18n } from "@/i18n/i18next"; import { UsageBalanceBar } from "./balance-bar"; import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; -import { formatUsageFreshness, type UsageRefresh } from "./model"; +import { formatAgo } from "./format"; +import type { UsageRefresh } from "./model"; import { useReportRefresh } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import type { UsageReport, UsageReportEntry, UsageWindow } from "./types"; import { UsageWindowBar } from "./window-bar"; +import { useRelativeTimeTick } from "./use-relative-time-tick"; function statusText(report: UsageReport): string | null { if (report.status === "available") return null; @@ -276,10 +278,13 @@ function UsageFreshness({ style: StyleProp; testID: string; }) { - const elapsed = useCompactTimeAgo(new Date(fetchedAt)); + const { t } = useTranslation(); + useRelativeTimeTick({ enabled: true }); + const relativeTime = formatAgo(fetchedAt); + if (!relativeTime) return null; return ( - {formatUsageFreshness(elapsed)} + {t("providerUsage.timing.updated", { relativeTime })} ); } diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts index e50bfb8172..5d167d2724 100644 --- a/packages/app/src/usage/copy.ts +++ b/packages/app/src/usage/copy.ts @@ -31,7 +31,6 @@ export const usageCopy = { return i18n.t("providerUsage.refreshing"); }, refreshFailed: "Unable to refresh usage", - updated: "Updated", get loading() { return i18n.t("providerUsage.loading"); }, @@ -43,9 +42,11 @@ export const usageCopy = { return i18n.t("providerUsage.errorTitle"); }, agentError: (reason: string) => `Unable to load usage: ${reason}`, - hostUnavailable: (host: string) => `Connect to ${host} to see usage`, - hostUpgradeRequired: (host: string) => `Update ${host} to see usage`, - clientUnavailable: "Host connection is not ready", + hostUnavailable: (host: string) => i18n.t("providerUsage.hostUnavailable", { host }), + hostUpgradeRequired: (host: string) => i18n.t("providerUsage.hostUpgradeRequired", { host }), + get clientUnavailable() { + return i18n.t("providerUsage.clientUnavailable"); + }, get retry() { return i18n.t("providerUsage.retry"); }, diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index cdb0e7dd26..6a5ece79cc 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -82,6 +82,12 @@ describe("formatAmount", () => { expect(formatProviderUsageLabel("five_hour", "Session")).toBe("세션"); expect(formatProviderUsageLabel("five_hour", "5-hour limit")).toBe("5-hour limit"); expect(formatProviderUsageLabel("monthly", "Monthly")).toBe("월간"); + expect(formatProviderUsageLabel("code_review:weekly", "Code review · Weekly")).toBe( + "코드 검토 · 주간", + ); + expect(formatProviderUsageLabel("code_review:five_hour", "Code review · 5-hour")).toBe( + "코드 검토 · 5-hour", + ); expect(formatProviderUsageLabel("credits", "Credits")).toBe("크레딧"); expect(formatProviderUsageLabel("custom_limit", "Custom limit")).toBe("Custom limit"); }); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index b025e3b117..97b8aecc43 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -12,6 +12,17 @@ const providerUsageLabelKeys = { } as const; export function formatProviderUsageLabel(id: string, fallback: string): string { + if (id.startsWith("code_review:")) { + const periodId = id.slice("code_review:".length); + const separator = " · "; + const periodFallback = fallback.includes(separator) + ? fallback.slice(fallback.lastIndexOf(separator) + separator.length) + : fallback; + return `${i18n.t(providerUsageLabelKeys.code_review)} · ${formatProviderUsageLabel( + periodId, + periodFallback, + )}`; + } const key = providerUsageLabelKeys[id as keyof typeof providerUsageLabelKeys] ?? // Claude exposes its session window with a five-hour ID. Other sources use that same ID for diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index a2c493619d..61fbec6843 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import { displayPercent, - formatUsageFreshness, resolveAgentUsageView, replaceReport, resolveUsageRefresh, @@ -50,7 +50,7 @@ describe("resolveUsageView", () => { supportsUsage: false, query: ready([entry({})]), }), - ).toEqual({ kind: "unavailable", message: "Update Laptop to see usage" }); + ).toEqual({ kind: "unavailable", message: "Update Laptop to see provider usage" }); }); it("asks for a connection before anything else", () => { @@ -61,7 +61,19 @@ describe("resolveUsageView", () => { supportsUsage: false, query: undefined, }), - ).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" }); + ).toEqual({ kind: "unavailable", message: "Connect to Laptop to see provider usage" }); + }); + + it("localizes unavailable host messages while retaining the host name", async () => { + await i18n.changeLanguage("ko"); + expect( + resolveUsageView({ + hostLabel: "Laptop", + isConnected: false, + supportsUsage: false, + query: undefined, + }), + ).toEqual({ kind: "unavailable", message: "Laptop에 연결하여 제공업체 사용량을 확인하세요" }); }); it("moves from loading to ready to error", () => { @@ -79,6 +91,10 @@ describe("resolveUsageView", () => { }); }); +afterEach(async () => { + await i18n.changeLanguage("en"); +}); + const hosts: UsageHost[] = [ { serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true }, { serverId: "old", label: "Old", isConnected: true, supportsUsage: false }, @@ -184,14 +200,6 @@ describe("displayPercent", () => { }); }); -describe("formatUsageFreshness", () => { - it("says when the report was fetched", () => { - expect(formatUsageFreshness("now")).toBe("Updated just now"); - expect(formatUsageFreshness("3m")).toBe("Updated 3m ago"); - expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15"); - }); -}); - describe("resolveUsageRefresh", () => { it("is pending while a refresh runs, even after an earlier failure", () => { expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending"); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index ed8a39163a..3b904061bb 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -1,4 +1,3 @@ -import { formatCompactTimeAgoAsProse } from "@/utils/time"; import { usageCopy } from "./copy"; import type { UsageDisplayAs } from "./preferences"; import type { UsageReportEntry, UsageView, UsageWindow } from "./types"; @@ -31,11 +30,6 @@ export function usageWindowRowLabel(input: { return `${input.pinLabel}, ${summary}`; } -/** When a report was fetched, from its compact relative time: "Updated 3m ago". */ -export function formatUsageFreshness(compactTimeAgo: string): string { - return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; -} - /** A user-requested refresh of one report. The previous report stays on screen throughout. */ export type UsageRefresh = "idle" | "pending" | "failed"; diff --git a/packages/app/src/usage/use-relative-time-tick.ts b/packages/app/src/usage/use-relative-time-tick.ts index c075612f40..2e59bd9ddc 100644 --- a/packages/app/src/usage/use-relative-time-tick.ts +++ b/packages/app/src/usage/use-relative-time-tick.ts @@ -2,7 +2,7 @@ import { useEffect, useReducer } from "react"; import { subscribeToRelativeTimeTick } from "@/utils/relative-time-ticker"; /** Re-renders a mounted usage label when its relative time can change. */ -export function useRelativeTimeTick(enabled: boolean): void { +export function useRelativeTimeTick({ enabled }: { enabled: boolean }): void { const [, tick] = useReducer((value: number) => value + 1, 0); useEffect(() => { diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 4971f6c2ee..a3693b06f3 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -50,7 +50,7 @@ export function UsageWindowBar({ const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; const relativeTime = isAtRisk ? window.runsOutAt : window.resetsAt; - useRelativeTimeTick(relativeTime != null); + useRelativeTimeTick({ enabled: relativeTime != null }); const trailing = isAtRisk ? formatRunsOutLabel(window.runsOutAt) : formatResetLabel(window.resetsAt);