Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion messages/en/myUsage.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,22 @@
"keyStats": "Key",
"userStats": "User",
"noData": "No data for selected period",
"unknownModel": "Unknown"
"unknownModel": "Unknown",
"modal": {
"requests": "Requests",
"tokens": "tokens",
"totalTokens": "Total Tokens",
"cost": "Cost",
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"cacheWrite": "Cache Write",
"cacheRead": "Cache Read",
"cacheHitRate": "Cache Hit Rate",
"cacheTokens": "Cache Tokens",
"performanceHigh": "High",
"performanceMedium": "Medium",
"performanceLow": "Low"
}
},
"accessRestrictions": {
"title": "Access Restrictions",
Expand Down
17 changes: 16 additions & 1 deletion messages/ja/myUsage.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,22 @@
"keyStats": "キー",
"userStats": "ユーザー",
"noData": "選択期間のデータがありません",
"unknownModel": "不明"
"unknownModel": "不明",
"modal": {
"requests": "リクエスト",
"tokens": "トークン",
"totalTokens": "トークン合計",
"cost": "コスト",
"inputTokens": "入力トークン",
"outputTokens": "出力トークン",
"cacheWrite": "キャッシュ書込",
"cacheRead": "キャッシュ読取",
"cacheHitRate": "キャッシュヒット率",
"cacheTokens": "キャッシュトークン",
"performanceHigh": "高",
"performanceMedium": "中",
"performanceLow": "低"
}
},
"accessRestrictions": {
"title": "アクセス制限",
Expand Down
17 changes: 16 additions & 1 deletion messages/ru/myUsage.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,22 @@
"keyStats": "Ключ",
"userStats": "Пользователь",
"noData": "Нет данных за выбранный период",
"unknownModel": "Неизвестно"
"unknownModel": "Неизвестно",
"modal": {
"requests": "Запросов",
"tokens": "токенов",
"totalTokens": "Всего токенов",
"cost": "Стоимость",
"inputTokens": "Входные токены",
"outputTokens": "Выходные токены",
"cacheWrite": "Запись кэша",
"cacheRead": "Чтение кэша",
"cacheHitRate": "Попадание кэша",
"cacheTokens": "Токены кэша",
"performanceHigh": "Высокий",
"performanceMedium": "Средний",
"performanceLow": "Низкий"
}
},
"accessRestrictions": {
"title": "Ограничения доступа",
Expand Down
17 changes: 16 additions & 1 deletion messages/zh-CN/myUsage.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,22 @@
"keyStats": "密钥",
"userStats": "用户",
"noData": "所选时段无数据",
"unknownModel": "未知"
"unknownModel": "未知",
"modal": {
"requests": "请求",
"tokens": "个token",
"totalTokens": "总Token",
"cost": "费用",
"inputTokens": "输入Token",
"outputTokens": "输出Token",
"cacheWrite": "缓存写入",
"cacheRead": "缓存读取",
"cacheHitRate": "缓存命中率",
"cacheTokens": "缓存Token",
"performanceHigh": "高",
"performanceMedium": "中",
"performanceLow": "低"
}
},
"accessRestrictions": {
"title": "访问限制",
Expand Down
17 changes: 16 additions & 1 deletion messages/zh-TW/myUsage.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,22 @@
"keyStats": "金鑰",
"userStats": "使用者",
"noData": "所選時段無資料",
"unknownModel": "不明"
"unknownModel": "不明",
"modal": {
"requests": "請求",
"tokens": "個token",
"totalTokens": "總Token",
"cost": "費用",
"inputTokens": "輸入Token",
"outputTokens": "輸出Token",
"cacheWrite": "快取寫入",
"cacheRead": "快取讀取",
"cacheHitRate": "快取命中率",
"cacheTokens": "快取Token",
"performanceHigh": "高",
"performanceMedium": "中",
"performanceLow": "低"
}
},
"accessRestrictions": {
"title": "存取限制",
Expand Down
60 changes: 40 additions & 20 deletions src/actions/my-usage.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"use server";

import { fromZonedTime } from "date-fns-tz";
import { and, eq, gte, isNull, lt, sql } from "drizzle-orm";
import { db } from "@/drizzle/db";
import { keys as keysTable, messageRequest } from "@/drizzle/schema";
import { getSession } from "@/lib/auth";
import { getEnvConfig } from "@/lib/config";
import { logger } from "@/lib/logger";
import { RateLimitService } from "@/lib/rate-limit/service";
import type { DailyResetMode } from "@/lib/rate-limit/time-utils";
Expand All @@ -23,6 +25,26 @@ import {
import type { BillingModelSource } from "@/types/system-config";
import type { ActionResult } from "./types";

/**
* Parse date range strings to timestamps using server timezone (TZ config).
* Returns startTime as midnight and endTime as next day midnight (exclusive upper bound).
*/
function parseDateRangeInServerTimezone(
startDate?: string,
endDate?: string
): { startTime?: number; endTime?: number } {
const timezone = getEnvConfig().TZ;
const parsedStart = startDate
? fromZonedTime(`${startDate}T00:00:00`, timezone).getTime()
: Number.NaN;
const parsedEnd = endDate ? fromZonedTime(`${endDate}T00:00:00`, timezone).getTime() : Number.NaN;

return {
startTime: Number.isFinite(parsedStart) ? parsedStart : undefined,
endTime: Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined,
};
}
Comment on lines +28 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

date-fns-tz fromZonedTime undefined timezone behavior

💡 Result:

Short answer: fromZonedTime has a known implementation issue — it reads date parts with getHours()/getFullYear() (local-time getters) and then builds a UTC Date from those parts, so the result is only correct if the environment's system time zone is UTC. That makes conversions wrong when your system TZ ≠ UTC (including cases where you pass undefined/omit a timeZone). See the GitHub issue describing the problem and the library docs for the intended behavior. [1][2]

What to do:

  • Always pass an explicit IANA time zone (e.g. "America/New_York"), and prefer toZonedTime / zonedTimeToUtc or formatInTimeZone for most flows. [2]
  • If you need a safe conversion regardless of the environment, convert/parse with explicit offsets or use functions that operate on UTC inputs instead. [2][1]

Sources:
[1] GitHub issue describing the fromZonedTime bug.
[2] date-fns-tz README / docs (function descriptions: fromZonedTime, toZonedTime, formatInTimeZone).


🏁 Script executed:

# Check the function definition and export type in the file
cat -n src/actions/my-usage.ts | sed -n '25,50p'

Repository: ding113/claude-code-hub

Length of output: 1184


🏁 Script executed:

# Find and examine getEnvConfig implementation
fd config -t f | grep -E "(config|lib)" | head -20

Repository: ding113/claude-code-hub

Length of output: 772


🏁 Script executed:

# Check imports and overall structure
head -n 50 src/actions/my-usage.ts

Repository: ding113/claude-code-hub

Length of output: 1963


🏁 Script executed:

# Find where parseDateRangeInServerTimezone is exported
grep -n "export.*parseDateRangeInServerTimezone" src/actions/my-usage.ts

Repository: ding113/claude-code-hub

Length of output: 49


🏁 Script executed:

# Check getEnvConfig implementation and TZ default value
cat -n src/lib/config/config.ts | head -100

Repository: ding113/claude-code-hub

Length of output: 367


🏁 Script executed:

# Verify export statement nearby
grep -n "export" src/actions/my-usage.ts | head -20

Repository: ding113/claude-code-hub

Length of output: 954


🏁 Script executed:

# Check env.schema for TZ configuration
fd env.schema -t f

Repository: ding113/claude-code-hub

Length of output: 95


🏁 Script executed:

# Look for TZ in the config files
rg "TZ" src/lib/config/ -A 3 -B 1

Repository: ding113/claude-code-hub

Length of output: 547


🏁 Script executed:

# Check if parseDateRangeInServerTimezone is used in getMyUsageLogs and getMyStatsSummary
sed -n '407,430p' src/actions/my-usage.ts

Repository: ding113/claude-code-hub

Length of output: 878


🏁 Script executed:

# Check if there are any comments or issues about timezone handling
rg "fromZonedTime\|zonedTimeToUtc\|toZonedTime" src/ -t ts -t tsx

Repository: ding113/claude-code-hub

Length of output: 94


🏁 Script executed:

# Verify the code structure around the function to understand context
sed -n '550,570p' src/actions/my-usage.ts

Repository: ding113/claude-code-hub

Length of output: 762


🏁 Script executed:

# Check if there are any other timezone-related utilities or fixes in the codebase
rg "timezone\|toZonedTime\|zonedTimeToUtc" src/ --type ts -C 2

Repository: ding113/claude-code-hub

Length of output: 49


🏁 Script executed:

# Verify the specific issue: understand how fromZonedTime is being used and what the expected behavior is
cat -n src/actions/my-usage.ts | sed -n '1,10p'

Repository: ding113/claude-code-hub

Length of output: 548


fromZonedTime 存在已知 Bug,会导致时区转换错误。

date-fns-tz 中的 fromZonedTime 函数存在实现缺陷:它使用本地时间的 getter 方法(getHours()getFullYear() 等)而非正确的 UTC 转换,只在系统时区为 UTC 时才能正常工作。即使配置了 TZ: "Asia/Shanghai",如果部署环境的系统时区不是 UTC,该函数返回的时间戳仍会错误。这会导致 getMyUsageLogsgetMyStatsSummary 返回的日期范围查询结果不准确。

改用 zonedTimeToUtc 或其他库提供的安全函数替代 fromZonedTime

🤖 Prompt for AI Agents
In `@src/actions/my-usage.ts` around lines 28 - 46, The
parseDateRangeInServerTimezone function uses date-fns-tz.fromZonedTime which is
buggy for non-UTC system timezones; replace calls to fromZonedTime in
parseDateRangeInServerTimezone with date-fns-tz.zonedTimeToUtc (or an equivalent
safe function) so you convert the local zone midnight string to a UTC timestamp
using the configured timezone from getEnvConfig().TZ, and keep the existing
logic that returns startTime as the midnight timestamp and endTime as that
timestamp plus 24*60*60*1000 when endDate is provided.


export interface MyUsageMetadata {
keyName: string;
keyProviderGroup: string | null;
Expand Down Expand Up @@ -395,16 +417,10 @@ export async function getMyUsageLogs(
const pageSize = Math.min(rawPageSize, 100);
const page = filters.page && filters.page > 0 ? filters.page : 1;

const parsedStart = filters.startDate
? new Date(`${filters.startDate}T00:00:00`).getTime()
: Number.NaN;
const parsedEnd = filters.endDate
? new Date(`${filters.endDate}T00:00:00`).getTime()
: Number.NaN;

const startTime = Number.isFinite(parsedStart) ? parsedStart : undefined;
// endTime 使用“次日零点”作为排他上界(created_at < endTime),避免 23:59:59.999 的边界问题
const endTime = Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined;
const { startTime, endTime } = parseDateRangeInServerTimezone(
filters.startDate,
filters.endDate
);

const usageFilters: UsageLogFilters = {
keyId: session.key.id,
Expand Down Expand Up @@ -519,6 +535,8 @@ export interface ModelBreakdownItem {
cost: number;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
}

export interface MyStatsSummary extends UsageLogSummary {
Expand All @@ -541,16 +559,10 @@ export async function getMyStatsSummary(
const settings = await getSystemSettings();
const currencyCode = settings.currencyDisplay;

// 日期字符串来自前端的 YYYY-MM-DD(目前使用 toISOString().split("T")[0] 生成),因此按 UTC 解析更一致。
// 注意:new Date("YYYY-MM-DDT00:00:00") 会按本地时区解析,可能导致跨时区边界偏移。
const parsedStart = filters.startDate
? Date.parse(`${filters.startDate}T00:00:00.000Z`)
: Number.NaN;
const parsedEnd = filters.endDate ? Date.parse(`${filters.endDate}T00:00:00.000Z`) : Number.NaN;

const startTime = Number.isFinite(parsedStart) ? parsedStart : undefined;
// endTime 使用“次日零点”作为排他上界(created_at < endTime),避免 23:59:59.999 的边界问题
const endTime = Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined;
const { startTime, endTime } = parseDateRangeInServerTimezone(
filters.startDate,
filters.endDate
);

// Get aggregated stats using existing repository function
const stats = await findUsageLogsStats({
Expand All @@ -567,6 +579,8 @@ export async function getMyStatsSummary(
cost: sql<string>`COALESCE(sum(${messageRequest.costUsd}), 0)`,
inputTokens: sql<number>`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`,
outputTokens: sql<number>`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`,
cacheCreationTokens: sql<number>`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`,
cacheReadTokens: sql<number>`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`,
})
.from(messageRequest)
.where(
Expand All @@ -589,6 +603,8 @@ export async function getMyStatsSummary(
cost: sql<string>`COALESCE(sum(${messageRequest.costUsd}), 0)`,
inputTokens: sql<number>`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`,
outputTokens: sql<number>`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`,
cacheCreationTokens: sql<number>`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::int`,
cacheReadTokens: sql<number>`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::int`,
})
.from(messageRequest)
.where(
Expand All @@ -611,13 +627,17 @@ export async function getMyStatsSummary(
cost: Number(row.cost ?? 0),
inputTokens: row.inputTokens,
outputTokens: row.outputTokens,
cacheCreationTokens: row.cacheCreationTokens,
cacheReadTokens: row.cacheReadTokens,
})),
userModelBreakdown: userBreakdown.map((row) => ({
model: row.model,
requests: row.requests,
cost: Number(row.cost ?? 0),
inputTokens: row.inputTokens,
outputTokens: row.outputTokens,
cacheCreationTokens: row.cacheCreationTokens,
cacheReadTokens: row.cacheReadTokens,
})),
currencyCode,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
{
header: t("columns.totalTokens"),
className: "text-right",
cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).totalTokens),
sortKey: "totalTokens",
getValue: (row) => (row as ProviderCacheHitRateEntry).totalTokens,
cell: (row) => formatTokenAmount((row as ProviderCacheHitRateEntry).totalInputTokens),
sortKey: "totalInputTokens",
getValue: (row) => (row as ProviderCacheHitRateEntry).totalInputTokens,
},
Comment on lines 299 to 305
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the specific file and lines
cat -n src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx | sed -n '295,310p'

Repository: ding113/claude-code-hub

Length of output: 774


🏁 Script executed:

# Find and check the i18n message files for the columns.totalTokens key
fd -type f -name "*.json" -path "*/messages/*" | head -20

Repository: ding113/claude-code-hub

Length of output: 237


🏁 Script executed:

# Search for columns.totalTokens in i18n files
rg "totalTokens" -t json src/

Repository: ding113/claude-code-hub

Length of output: 49


🏁 Script executed:

# Find all message/i18n JSON files
find . -type f -name "*.json" -path "*messages*" | head -20

Repository: ding113/claude-code-hub

Length of output: 705


🏁 Script executed:

# Search for totalTokens more broadly, not just in src/
rg "totalTokens" --type json

Repository: ding113/claude-code-hub

Length of output: 1600


🏁 Script executed:

# Check what changed in the PR by looking at git diff for i18n files
git diff --name-only | grep -i messages

Repository: ding113/claude-code-hub

Length of output: 49


🏁 Script executed:

# Check the structure of dashboard.json files, specifically looking for columns key
rg -A 2 -B 2 "columns" messages/en/dashboard.json | head -50

Repository: ding113/claude-code-hub

Length of output: 1045


🏁 Script executed:

# Let's look at the actual JSON structure around totalTokens in dashboard files
python3 << 'EOF'
import json
import glob

for file in glob.glob("messages/*/dashboard.json"):
    try:
        with open(file, 'r', encoding='utf-8') as f:
            data = json.load(f)
        # Check if 'columns' key exists
        if 'columns' in data:
            print(f"\n=== {file} ===")
            print("columns keys:", list(data['columns'].keys()))
            if 'totalTokens' in data['columns']:
                print(f"columns.totalTokens = {data['columns']['totalTokens']}")
        # Also check leaderboard section
        if 'leaderboard' in data and isinstance(data['leaderboard'], dict):
            if 'columns' in data['leaderboard']:
                print(f"\n=== {file} (leaderboard.columns) ===")
                print("leaderboard.columns keys:", list(data['leaderboard']['columns'].keys())[:10])
                if 'totalTokens' in data['leaderboard']['columns']:
                    print(f"leaderboard.columns.totalTokens = {data['leaderboard']['columns']['totalTokens']}")
    except Exception as e:
        print(f"Error reading {file}: {e}")
EOF

Repository: ding113/claude-code-hub

Length of output: 153


🏁 Script executed:

# Extract just the leaderboard section from one dashboard file
jq '.leaderboard' messages/en/dashboard.json 2>/dev/null | head -100

Repository: ding113/claude-code-hub

Length of output: 2567


🏁 Script executed:

# Search for both totalTokens and totalInputTokens in the message files
rg "totalInputTokens" messages/

Repository: ding113/claude-code-hub

Length of output: 49


🏁 Script executed:

# Check the exact structure - look for where columns.totalTokens is defined
rg -B 5 "totalTokens" messages/en/dashboard.json | grep -E "(leaderboard|columns|totalTokens)" | head -20

Repository: ding113/claude-code-hub

Length of output: 140


列标题与实际数据语义不匹配,需要更新国际化 key

逻辑变更正确:将 totalInputTokens 用于缓存命中率计算符合 PR 目标,与已有经验一致(缓存计算仅包含输入相关令牌)。

但存在 UX 问题:第 300 行的列标题使用 t("columns.totalTokens"),该 i18n key 在所有语言文件中均定义为"总 Token 数"/"Total Tokens",隐含包含所有 Token 类型(输入+输出)。而实际显示的数据仅为 totalInputTokens(输入令牌),造成语义不符。

需修复

  • 创建新的 i18n key(如 columns.totalInputTokens),在 5 个语言文件中定义为"总输入 Token 数"等
  • 或更新列标题代码为 t("columns.totalInputTokens")

否则用户会误解为该列显示全部 Token。

🤖 Prompt for AI Agents
In `@src/app/`[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
around lines 299 - 305, The column header currently uses
t("columns.totalTokens") but the cell/getValue use
ProviderCacheHitRateEntry.totalInputTokens so update the i18n key to reflect
input-only tokens: add a new key (e.g. "columns.totalInputTokens") to all
language files and change the column header invocation to
t("columns.totalInputTokens"); ensure the new translations (e.g. "总输入 Token
数"/"Total Input Tokens") are present in each locale and leave the
cell/getValue/sortKey referencing totalInputTokens unchanged.

];

Expand Down
12 changes: 1 addition & 11 deletions src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,13 @@ interface CollapsibleQuotaCardProps {
quota: MyUsageQuota | null;
loading?: boolean;
currencyCode?: CurrencyCode;
keyExpiresAt?: Date | null;
userExpiresAt?: Date | null;
defaultOpen?: boolean;
}

export function CollapsibleQuotaCard({
quota,
loading = false,
currencyCode = "USD",
keyExpiresAt,
userExpiresAt,
defaultOpen = false,
}: CollapsibleQuotaCardProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
Expand Down Expand Up @@ -164,13 +160,7 @@ export function CollapsibleQuotaCard({

<CollapsibleContent>
<div className="p-4">
<QuotaCards
quota={quota}
loading={loading}
currencyCode={currencyCode}
keyExpiresAt={keyExpiresAt}
userExpiresAt={userExpiresAt}
/>
<QuotaCards quota={quota} loading={loading} currencyCode={currencyCode} />
</div>
</CollapsibleContent>
</div>
Expand Down
18 changes: 14 additions & 4 deletions src/app/[locale]/my-usage/_components/expiration-info.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { Clock } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { QuotaCountdownCompact } from "@/components/quota/quota-countdown";
import { useCountdown } from "@/hooks/useCountdown";
import { cn } from "@/lib/utils";
import { formatDate, getLocaleDateFormat } from "@/lib/utils/date-format";
Expand Down Expand Up @@ -56,6 +56,14 @@ export function ExpirationInfo({
expired: "text-destructive",
};

const countdownStyles: Record<ExpireStatus, string> = {
none: "text-muted-foreground",
normal: "text-emerald-600 dark:text-emerald-400",
warning: "text-amber-600 dark:text-amber-400",
danger: "text-red-600 dark:text-red-400",
expired: "text-destructive",
};

const renderItem = (
label: string,
value: Date | null,
Expand All @@ -79,9 +87,11 @@ export function ExpirationInfo({
</span>
</div>
{showCountdown ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t("expiresIn", { time: countdown.shortFormatted })}</span>
<QuotaCountdownCompact resetAt={value} />
<div className={cn("flex items-center gap-1.5 pt-1", countdownStyles[status])}>
<Clock className="h-3.5 w-3.5" />
<span className="text-sm font-semibold font-mono tabular-nums">
{countdown.shortFormatted}
</span>
</div>
) : null}
</div>
Expand Down
Loading
Loading