fix(app): complete provider usage localization

This commit is contained in:
Michael Wu
2026-10-18 05:24:40 +09:00
parent dacccf1374
commit d60c35bff2
18 changed files with 82 additions and 49 deletions
+2 -2
View File
@@ -2050,8 +2050,8 @@ export const ar: TranslationResources = {
loading: "جارٍ تحميل بيانات الاستخدام...",
empty: "لا توجد بيانات استخدام",
errorTitle: "تعذّر تحميل بيانات الاستخدام",
hostUnavailable: "اتصل بهذا المضيف لعرض استخدام المزوّد",
hostUpgradeRequired: "حدّث المضيف لعرض استخدام المزوّد",
hostUnavailable: "اتصل بـ{{host}} لعرض استخدام المزوّد",
hostUpgradeRequired: "حدّث {{host}} لعرض استخدام المزوّد",
clientUnavailable: "اتصال المضيف غير جاهز",
retry: "حاول مرة أخرى",
tooltipLoading: "جارٍ تحميل استخدام الخطة...",
+2 -2
View File
@@ -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...",
+2 -2
View File
@@ -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...",
+2 -2
View File
@@ -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...",
+2 -2
View File
@@ -2071,8 +2071,8 @@ export const ja: TranslationResources = {
loading: "使用状況を読み込み中...",
empty: "使用状況データがありません",
errorTitle: "使用状況を読み込めません",
hostUnavailable: "このホストに接続してプロバイダーの使用状況を確認してください",
hostUpgradeRequired: "ホストを更新してプロバイダーの使用状況を確認してください",
hostUnavailable: "{{host}} に接続してプロバイダーの使用状況を確認してください",
hostUpgradeRequired: "{{host}} を更新してプロバイダーの使用状況を確認してください",
clientUnavailable: "ホスト接続の準備ができていません",
retry: "再試行",
tooltipLoading: "プラン使用状況を読み込み中...",
+2 -2
View File
@@ -2060,8 +2060,8 @@ export const ko: TranslationResources = {
loading: "사용량 불러오는 중...",
empty: "사용량 데이터가 없습니다",
errorTitle: "사용량을 불러올 수 없습니다",
hostUnavailable: "이 호스트에 연결하여 제공업체 사용량을 확인하세요",
hostUpgradeRequired: "호스트를 업데이트하여 제공업체 사용량을 확인하세요",
hostUnavailable: "{{host}}에 연결하여 제공업체 사용량을 확인하세요",
hostUpgradeRequired: "{{host}}를 업데이트하여 제공업체 사용량을 확인하세요",
clientUnavailable: "호스트 연결이 준비되지 않았습니다",
retry: "다시 시도",
tooltipLoading: "플랜 사용량 불러오는 중...",
+2 -2
View File
@@ -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...",
+2 -2
View File
@@ -2083,8 +2083,8 @@ export const ru: TranslationResources = {
loading: "Загрузка данных об использовании...",
empty: "Нет данных об использовании",
errorTitle: "Не удалось загрузить данные об использовании",
hostUnavailable: "Подключитесь к этому хосту, чтобы увидеть использование провайдера",
hostUpgradeRequired: "Обновите хост, чтобы увидеть использование провайдера",
hostUnavailable: "Подключитесь к {{host}}, чтобы увидеть использование провайдера",
hostUpgradeRequired: "Обновите {{host}}, чтобы увидеть использование провайдера",
clientUnavailable: "Соединение с хостом не готово",
retry: "Повторить",
tooltipLoading: "Загрузка использования плана...",
+2 -2
View File
@@ -2028,8 +2028,8 @@ export const zhCN: TranslationResources = {
loading: "正在加载用量...",
empty: "暂无用量数据",
errorTitle: "无法加载用量",
hostUnavailable: "连接到此主机以查看提供商用量",
hostUpgradeRequired: "更新主机以查看提供商用量",
hostUnavailable: "连接到 {{host}} 以查看提供商用量",
hostUpgradeRequired: "更新 {{host}} 以查看提供商用量",
clientUnavailable: "主机连接尚未就绪",
retry: "重试",
tooltipLoading: "正在加载套餐用量...",
+11 -3
View File
@@ -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);
+9 -4
View File
@@ -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<TextStyle>;
testID: string;
}) {
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
const { t } = useTranslation();
useRelativeTimeTick({ enabled: true });
const relativeTime = formatAgo(fetchedAt);
if (!relativeTime) return null;
return (
<Text style={style} numberOfLines={1} testID={testID}>
{formatUsageFreshness(elapsed)}
{t("providerUsage.timing.updated", { relativeTime })}
</Text>
);
}
+5 -4
View File
@@ -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");
},
+6
View File
@@ -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");
});
+11
View File
@@ -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
+20 -12
View File
@@ -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");
-6
View File
@@ -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";
@@ -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(() => {
+1 -1
View File
@@ -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);