Przeglądaj źródła

fix(my-usage): UX improvements for quota and statistics cards (#794)

* style(my-usage): use Badge for provider group values

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(my-usage): use currency symbol instead of code in quota cards

Replace manual `${currency} ${num.toFixed(2)}` formatting with
`formatCurrency()` so quota values display "$3.50" instead of "USD 3.50",
consistent with all other currency displays in the app.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style(my-usage): replace unlimited text with infinity icon in quota cards

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(my-usage): paginate model breakdown in statistics summary card

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore(my-usage): suppress biome exhaustive-deps for intentional stats reset

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(my-usage): address PR #794 review comments

- Fix abbreviateModel/abbreviateClient crash on empty split parts
- Fix pagination reset on auto-refresh by using dateRange deps
- Restore noData fallback in model breakdown columns
- Add i18n for pagination controls with aria-labels (5 langs)
- Fix quota label overflow for long translations (w-8 -> w-auto)
- Rename Infinity -> InfinityIcon to avoid shadowing global
- Remove redundant span wrappers in TooltipTrigger asChild

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: John Doe <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
miraserver 4 dni temu
rodzic
commit
084c940b53

+ 3 - 0
messages/en/myUsage.json

@@ -92,6 +92,9 @@
     "keyStats": "Key",
     "keyStats": "Key",
     "userStats": "User",
     "userStats": "User",
     "noData": "No data for selected period",
     "noData": "No data for selected period",
+    "breakdownPrevPage": "Previous page",
+    "breakdownNextPage": "Next page",
+    "breakdownPageIndicator": "{current} / {total}",
     "unknownModel": "Unknown",
     "unknownModel": "Unknown",
     "modal": {
     "modal": {
       "requests": "Requests",
       "requests": "Requests",

+ 3 - 0
messages/ja/myUsage.json

@@ -92,6 +92,9 @@
     "keyStats": "キー",
     "keyStats": "キー",
     "userStats": "ユーザー",
     "userStats": "ユーザー",
     "noData": "選択期間のデータがありません",
     "noData": "選択期間のデータがありません",
+    "breakdownPrevPage": "前のページ",
+    "breakdownNextPage": "次のページ",
+    "breakdownPageIndicator": "{current} / {total}",
     "unknownModel": "不明",
     "unknownModel": "不明",
     "modal": {
     "modal": {
       "requests": "リクエスト",
       "requests": "リクエスト",

+ 3 - 0
messages/ru/myUsage.json

@@ -92,6 +92,9 @@
     "keyStats": "Ключ",
     "keyStats": "Ключ",
     "userStats": "Пользователь",
     "userStats": "Пользователь",
     "noData": "Нет данных за выбранный период",
     "noData": "Нет данных за выбранный период",
+    "breakdownPrevPage": "Предыдущая страница",
+    "breakdownNextPage": "Следующая страница",
+    "breakdownPageIndicator": "{current} / {total}",
     "unknownModel": "Неизвестно",
     "unknownModel": "Неизвестно",
     "modal": {
     "modal": {
       "requests": "Запросов",
       "requests": "Запросов",

+ 3 - 0
messages/zh-CN/myUsage.json

@@ -92,6 +92,9 @@
     "keyStats": "密钥",
     "keyStats": "密钥",
     "userStats": "用户",
     "userStats": "用户",
     "noData": "所选时段无数据",
     "noData": "所选时段无数据",
+    "breakdownPrevPage": "上一页",
+    "breakdownNextPage": "下一页",
+    "breakdownPageIndicator": "{current} / {total}",
     "unknownModel": "未知",
     "unknownModel": "未知",
     "modal": {
     "modal": {
       "requests": "请求",
       "requests": "请求",

+ 3 - 0
messages/zh-TW/myUsage.json

@@ -92,6 +92,9 @@
     "keyStats": "金鑰",
     "keyStats": "金鑰",
     "userStats": "使用者",
     "userStats": "使用者",
     "noData": "所選時段無資料",
     "noData": "所選時段無資料",
+    "breakdownPrevPage": "上一頁",
+    "breakdownNextPage": "下一頁",
+    "breakdownPageIndicator": "{current} / {total}",
     "unknownModel": "不明",
     "unknownModel": "不明",
     "modal": {
     "modal": {
       "requests": "請求",
       "requests": "請求",

+ 4 - 4
src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx

@@ -1,6 +1,6 @@
 "use client";
 "use client";
 
 
-import { AlertTriangle, ChevronDown, Infinity, PieChart } from "lucide-react";
+import { AlertTriangle, ChevronDown, Infinity as InfinityIcon, PieChart } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { useState } from "react";
 import { useState } from "react";
 import type { MyUsageQuota } from "@/actions/my-usage";
 import type { MyUsageQuota } from "@/actions/my-usage";
@@ -94,7 +94,7 @@ export function CollapsibleQuotaCard({
                 <div className="flex items-center gap-1.5">
                 <div className="flex items-center gap-1.5">
                   <span className="text-muted-foreground">{t("daily")}:</span>
                   <span className="text-muted-foreground">{t("daily")}:</span>
                   {dailyPct === null ? (
                   {dailyPct === null ? (
-                    <Infinity className="h-4 w-4 text-muted-foreground" />
+                    <InfinityIcon className="h-4 w-4 text-muted-foreground" />
                   ) : (
                   ) : (
                     <>
                     <>
                       <span className={cn("font-semibold", getPercentColor(dailyPct))}>
                       <span className={cn("font-semibold", getPercentColor(dailyPct))}>
@@ -108,7 +108,7 @@ export function CollapsibleQuotaCard({
                 <div className="flex items-center gap-1.5">
                 <div className="flex items-center gap-1.5">
                   <span className="text-muted-foreground">{t("monthly")}:</span>
                   <span className="text-muted-foreground">{t("monthly")}:</span>
                   {monthlyPct === null ? (
                   {monthlyPct === null ? (
-                    <Infinity className="h-4 w-4 text-muted-foreground" />
+                    <InfinityIcon className="h-4 w-4 text-muted-foreground" />
                   ) : (
                   ) : (
                     <>
                     <>
                       <span className={cn("font-semibold", getPercentColor(monthlyPct))}>
                       <span className={cn("font-semibold", getPercentColor(monthlyPct))}>
@@ -122,7 +122,7 @@ export function CollapsibleQuotaCard({
                 <div className="flex items-center gap-1.5">
                 <div className="flex items-center gap-1.5">
                   <span className="text-muted-foreground">{t("total")}:</span>
                   <span className="text-muted-foreground">{t("total")}:</span>
                   {totalPct === null ? (
                   {totalPct === null ? (
-                    <Infinity className="h-4 w-4 text-muted-foreground" />
+                    <InfinityIcon className="h-4 w-4 text-muted-foreground" />
                   ) : (
                   ) : (
                     <>
                     <>
                       <span className={cn("font-semibold", getPercentColor(totalPct))}>
                       <span className={cn("font-semibold", getPercentColor(totalPct))}>

+ 109 - 16
src/app/[locale]/my-usage/_components/provider-group-info.tsx

@@ -2,8 +2,65 @@
 
 
 import { Layers, ShieldCheck } from "lucide-react";
 import { Layers, ShieldCheck } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
+import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { cn } from "@/lib/utils";
 import { cn } from "@/lib/utils";
 
 
+function abbreviateModel(name: string): string {
+  const parts = name.split("-").filter(Boolean);
+
+  if (parts.length === 1) {
+    return parts[0].length <= 4 ? parts[0].toUpperCase() : parts[0].slice(0, 2).toUpperCase();
+  }
+
+  const letterParts: string[] = [];
+  let versionMixed = "";
+  const versionNums: string[] = [];
+
+  for (const part of parts) {
+    if (/^\d{8,}$/.test(part)) continue;
+    if (/^[a-zA-Z]+$/.test(part)) {
+      letterParts.push(part);
+    } else if (/^\d+\.\d+$/.test(part)) {
+      versionMixed = part;
+    } else if (/^\d+[a-zA-Z]/.test(part)) {
+      versionMixed = part;
+    } else if (/^\d+$/.test(part)) {
+      versionNums.push(part);
+    } else {
+      letterParts.push(part);
+    }
+  }
+
+  const prefix = letterParts
+    .slice(0, 3)
+    .map((w) => w[0].toUpperCase())
+    .join("");
+
+  let version = "";
+  if (versionMixed) {
+    version = versionMixed;
+  } else if (versionNums.length > 0) {
+    version = versionNums.slice(0, 2).join(".");
+  }
+
+  if (version && prefix) {
+    return `${prefix}-${version}`;
+  }
+  return prefix || name.toUpperCase().substring(0, 3);
+}
+
+function abbreviateClient(name: string): string {
+  const parts = name.split(/[-\s]+/).filter(Boolean);
+  if (parts.length === 1) {
+    return name.slice(0, 2).toUpperCase();
+  }
+  return parts
+    .slice(0, 3)
+    .map((w) => w[0].toUpperCase())
+    .join("");
+}
+
 interface ProviderGroupInfoProps {
 interface ProviderGroupInfoProps {
   keyProviderGroup: string | null;
   keyProviderGroup: string | null;
   userProviderGroup: string | null;
   userProviderGroup: string | null;
@@ -26,10 +83,8 @@ export function ProviderGroupInfo({
   const userDisplay = userProviderGroup ?? tGroup("allProviders");
   const userDisplay = userProviderGroup ?? tGroup("allProviders");
   const inherited = !keyProviderGroup && !!userProviderGroup;
   const inherited = !keyProviderGroup && !!userProviderGroup;
 
 
-  const modelsDisplay =
-    userAllowedModels.length > 0 ? userAllowedModels.join(", ") : tRestrictions("noRestrictions");
-  const clientsDisplay =
-    userAllowedClients.length > 0 ? userAllowedClients.join(", ") : tRestrictions("noRestrictions");
+  const hasModels = userAllowedModels.length > 0;
+  const hasClients = userAllowedClients.length > 0;
 
 
   return (
   return (
     <div
     <div
@@ -45,16 +100,20 @@ export function ProviderGroupInfo({
           <span>{tGroup("title")}</span>
           <span>{tGroup("title")}</span>
         </div>
         </div>
         <div className="space-y-1">
         <div className="space-y-1">
-          <div className="flex items-baseline gap-1.5">
-            <span className="text-xs text-muted-foreground">{tGroup("keyGroup")}:</span>
-            <span className="text-sm font-semibold text-foreground">{keyDisplay}</span>
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="shrink-0 text-xs text-muted-foreground">{tGroup("keyGroup")}:</span>
+            <Badge variant="outline" className="cursor-default text-xs">
+              {keyDisplay}
+            </Badge>
             {inherited && (
             {inherited && (
               <span className="text-xs text-muted-foreground">({tGroup("inheritedFromUser")})</span>
               <span className="text-xs text-muted-foreground">({tGroup("inheritedFromUser")})</span>
             )}
             )}
           </div>
           </div>
-          <div className="flex items-baseline gap-1.5">
-            <span className="text-xs text-muted-foreground">{tGroup("userGroup")}:</span>
-            <span className="text-sm font-semibold text-foreground">{userDisplay}</span>
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="shrink-0 text-xs text-muted-foreground">{tGroup("userGroup")}:</span>
+            <Badge variant="outline" className="cursor-default text-xs">
+              {userDisplay}
+            </Badge>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
@@ -66,13 +125,47 @@ export function ProviderGroupInfo({
           <span>{tRestrictions("title")}</span>
           <span>{tRestrictions("title")}</span>
         </div>
         </div>
         <div className="space-y-1">
         <div className="space-y-1">
-          <div className="flex items-baseline gap-1.5">
-            <span className="text-xs text-muted-foreground">{tRestrictions("models")}:</span>
-            <span className="text-sm font-semibold text-foreground">{modelsDisplay}</span>
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="shrink-0 text-xs text-muted-foreground">
+              {tRestrictions("models")}:
+            </span>
+            {hasModels ? (
+              userAllowedModels.map((name) => (
+                <Tooltip key={name}>
+                  <TooltipTrigger asChild>
+                    <Badge variant="outline" className="cursor-default font-mono text-xs">
+                      {abbreviateModel(name)}
+                    </Badge>
+                  </TooltipTrigger>
+                  <TooltipContent>{name}</TooltipContent>
+                </Tooltip>
+              ))
+            ) : (
+              <span className="text-sm font-semibold text-foreground">
+                {tRestrictions("noRestrictions")}
+              </span>
+            )}
           </div>
           </div>
-          <div className="flex items-baseline gap-1.5">
-            <span className="text-xs text-muted-foreground">{tRestrictions("clients")}:</span>
-            <span className="text-sm font-semibold text-foreground">{clientsDisplay}</span>
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="shrink-0 text-xs text-muted-foreground">
+              {tRestrictions("clients")}:
+            </span>
+            {hasClients ? (
+              userAllowedClients.map((name) => (
+                <Tooltip key={name}>
+                  <TooltipTrigger asChild>
+                    <Badge variant="outline" className="cursor-default font-mono text-xs">
+                      {abbreviateClient(name)}
+                    </Badge>
+                  </TooltipTrigger>
+                  <TooltipContent>{name}</TooltipContent>
+                </Tooltip>
+              ))
+            ) : (
+              <span className="text-sm font-semibold text-foreground">
+                {tRestrictions("noRestrictions")}
+              </span>
+            )}
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>

+ 101 - 98
src/app/[locale]/my-usage/_components/quota-cards.tsx

@@ -1,13 +1,14 @@
 "use client";
 "use client";
 
 
+import { Infinity as InfinityIcon } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { useMemo } from "react";
 import { useMemo } from "react";
 import type { MyUsageQuota } from "@/actions/my-usage";
 import type { MyUsageQuota } from "@/actions/my-usage";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Progress } from "@/components/ui/progress";
 import { Progress } from "@/components/ui/progress";
 import { Skeleton } from "@/components/ui/skeleton";
 import { Skeleton } from "@/components/ui/skeleton";
 import type { CurrencyCode } from "@/lib/utils";
 import type { CurrencyCode } from "@/lib/utils";
 import { cn } from "@/lib/utils";
 import { cn } from "@/lib/utils";
+import { formatCurrency } from "@/lib/utils/currency";
 import { calculateUsagePercent, isUnlimited } from "@/lib/utils/limit-helpers";
 import { calculateUsagePercent, isUnlimited } from "@/lib/utils/limit-helpers";
 
 
 interface QuotaCardsProps {
 interface QuotaCardsProps {
@@ -79,144 +80,146 @@ export function QuotaCards({ quota, loading = false, currencyCode = "USD" }: Quo
   }
   }
 
 
   return (
   return (
-    <div className="space-y-3">
-      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
-        {items.map((item) => {
-          const keyPct = calculateUsagePercent(item.keyCurrent, item.keyLimit);
-          const userPct = calculateUsagePercent(item.userCurrent ?? 0, item.userLimit);
-
-          const keyTone = getTone(keyPct);
-          const userTone = getTone(userPct);
-          const hasUserData = item.userLimit !== null || item.userCurrent !== null;
-
-          return (
-            <Card key={item.key} className="border-border/70">
-              <CardHeader className="pb-3">
-                <CardTitle className="text-sm font-semibold text-muted-foreground">
-                  {item.title}
-                </CardTitle>
-              </CardHeader>
-              <CardContent className="space-y-3">
-                <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
-                  <QuotaColumn
-                    label={t("keyLevel")}
-                    current={item.keyCurrent}
-                    limit={item.keyLimit}
-                    percent={keyPct}
-                    tone={keyTone}
-                    currency={item.key === "concurrent" ? undefined : currencyCode}
-                  />
-                  <QuotaColumn
-                    label={t("userLevel")}
-                    current={item.userCurrent ?? 0}
-                    limit={item.userLimit}
-                    percent={userPct}
-                    tone={userTone}
-                    currency={item.key === "concurrent" ? undefined : currencyCode}
-                    muted={!hasUserData}
-                  />
-                </div>
-              </CardContent>
-            </Card>
-          );
-        })}
-        {items.length === 0 && !loading ? (
-          <Card>
-            <CardContent className="py-6 text-center text-sm text-muted-foreground">
-              {t("empty")}
-            </CardContent>
-          </Card>
-        ) : null}
-      </div>
+    <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
+      {items.map((item) => {
+        const isCurrency = item.key !== "concurrent";
+        const currency = isCurrency ? currencyCode : undefined;
+
+        return (
+          <QuotaBlock
+            key={item.key}
+            title={item.title}
+            keyCurrent={item.keyCurrent}
+            keyLimit={item.keyLimit}
+            userCurrent={item.userCurrent ?? 0}
+            userLimit={item.userLimit}
+            currency={currency}
+          />
+        );
+      })}
+      {items.length === 0 && !loading ? (
+        <div className="col-span-full py-6 text-center text-sm text-muted-foreground">
+          {t("empty")}
+        </div>
+      ) : null}
     </div>
     </div>
   );
   );
 }
 }
 
 
-function QuotaCardsSkeleton({ label }: { label: string }) {
+function QuotaBlock({
+  title,
+  keyCurrent,
+  keyLimit,
+  userCurrent,
+  userLimit,
+  currency,
+}: {
+  title: string;
+  keyCurrent: number;
+  keyLimit: number | null;
+  userCurrent: number;
+  userLimit: number | null;
+  currency?: CurrencyCode;
+}) {
+  const t = useTranslations("myUsage.quota");
+
+  const keyPct = calculateUsagePercent(keyCurrent, keyLimit);
+  const userPct = calculateUsagePercent(userCurrent, userLimit);
+
   return (
   return (
-    <div className="space-y-3" aria-busy="true">
-      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
-        {Array.from({ length: 6 }).map((_, index) => (
-          <Card key={index} className="border-border/70">
-            <CardHeader className="pb-3">
-              <Skeleton className="h-4 w-20" />
-            </CardHeader>
-            <CardContent className="space-y-3">
-              <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
-                <Skeleton className="h-16 w-full" />
-                <Skeleton className="h-16 w-full" />
-              </div>
-            </CardContent>
-          </Card>
-        ))}
-      </div>
-      <div className="flex items-center gap-2 text-xs text-muted-foreground">
-        <Skeleton className="h-3 w-3 rounded-full" />
-        <span>{label}</span>
-      </div>
+    <div className="space-y-2 rounded-md border bg-card/50 p-3">
+      <div className="text-xs font-semibold text-muted-foreground">{title}</div>
+      <QuotaRow
+        label={t("keyLevel")}
+        current={keyCurrent}
+        limit={keyLimit}
+        percent={keyPct}
+        currency={currency}
+      />
+      <QuotaRow
+        label={t("userLevel")}
+        current={userCurrent}
+        limit={userLimit}
+        percent={userPct}
+        currency={currency}
+      />
     </div>
     </div>
   );
   );
 }
 }
 
 
-function QuotaColumn({
+function QuotaRow({
   label,
   label,
   current,
   current,
   limit,
   limit,
   percent,
   percent,
-  tone,
   currency,
   currency,
-  muted = false,
 }: {
 }: {
   label: string;
   label: string;
   current: number;
   current: number;
   limit: number | null;
   limit: number | null;
   percent: number | null;
   percent: number | null;
-  tone: "default" | "warn" | "danger";
-  currency?: string;
-  muted?: boolean;
+  currency?: CurrencyCode;
 }) {
 }) {
   const t = useTranslations("myUsage.quota");
   const t = useTranslations("myUsage.quota");
+  const unlimited = isUnlimited(limit);
+  const tone = getTone(percent);
 
 
   const formatValue = (value: number) => {
   const formatValue = (value: number) => {
     const num = Number(value);
     const num = Number(value);
-    if (!Number.isFinite(num)) {
-      return currency ? `${currency} 0.00` : "0";
-    }
-    return currency ? `${currency} ${num.toFixed(2)}` : String(num);
+    if (!Number.isFinite(num)) return currency ? formatCurrency(0, currency) : "0";
+    return currency ? formatCurrency(num, currency) : String(num);
   };
   };
 
 
-  const unlimited = isUnlimited(limit);
+  const limitDisplay = unlimited ? t("unlimited") : formatValue(limit as number);
+  const ariaLabel = `${label}: ${formatValue(current)}${!unlimited ? ` / ${limitDisplay}` : ""}`;
 
 
-  const progressClass = cn("h-2", {
+  const progressClass = cn("h-1.5 flex-1", {
     "bg-destructive/10 [&>div]:bg-destructive": tone === "danger",
     "bg-destructive/10 [&>div]:bg-destructive": tone === "danger",
     "bg-amber-500/10 [&>div]:bg-amber-500": tone === "warn",
     "bg-amber-500/10 [&>div]:bg-amber-500": tone === "warn",
   });
   });
 
 
-  const limitDisplay = unlimited ? t("unlimited") : formatValue(limit as number);
-  const ariaLabel = `${label}: ${formatValue(current)}${!unlimited ? ` / ${limitDisplay}` : ""}`;
-
   return (
   return (
-    <div className={cn("space-y-2 rounded-md border bg-card/50 p-3", muted && "opacity-70")}>
-      {/* Label */}
-      <div className="text-xs font-medium text-muted-foreground">{label}</div>
-
-      {/* Values - split into two lines to avoid overlap */}
-      <div className="space-y-0.5">
-        <div className="text-sm font-mono font-medium text-foreground">{formatValue(current)}</div>
-        <div className="text-xs text-muted-foreground">/ {limitDisplay}</div>
-      </div>
-
-      {/* Progress bar or placeholder */}
+    <div className="flex items-center gap-2">
+      <span className="w-auto shrink-0 whitespace-nowrap text-[11px] text-muted-foreground">
+        {label}
+      </span>
       {!unlimited ? (
       {!unlimited ? (
         <Progress value={percent ?? 0} className={progressClass} aria-label={ariaLabel} />
         <Progress value={percent ?? 0} className={progressClass} aria-label={ariaLabel} />
       ) : (
       ) : (
         <div
         <div
-          className="h-2 rounded-full bg-muted/50"
+          className="h-1.5 flex-1 rounded-full bg-muted/50"
           role="progressbar"
           role="progressbar"
           aria-label={`${label}: ${t("unlimited")}`}
           aria-label={`${label}: ${t("unlimited")}`}
           aria-valuetext={t("unlimited")}
           aria-valuetext={t("unlimited")}
         />
         />
       )}
       )}
+      <span className="shrink-0 text-right font-mono text-xs text-foreground">
+        {formatValue(current)}
+        <span className="text-muted-foreground">
+          {" / "}
+          {unlimited ? <InfinityIcon className="inline h-3.5 w-3.5" /> : limitDisplay}
+        </span>
+      </span>
+    </div>
+  );
+}
+
+function QuotaCardsSkeleton({ label }: { label: string }) {
+  return (
+    <div className="space-y-3" aria-busy="true">
+      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
+        {Array.from({ length: 6 }).map((_, index) => (
+          <div key={index} className="space-y-2 rounded-md border bg-card/50 p-3">
+            <Skeleton className="h-3 w-16" />
+            <Skeleton className="h-4 w-full" />
+            <Skeleton className="h-4 w-full" />
+          </div>
+        ))}
+      </div>
+      <div className="flex items-center gap-2 text-xs text-muted-foreground">
+        <Skeleton className="h-3 w-3 rounded-full" />
+        <span>{label}</span>
+      </div>
     </div>
     </div>
   );
   );
 }
 }

+ 111 - 39
src/app/[locale]/my-usage/_components/statistics-summary-card.tsx

@@ -6,6 +6,8 @@ import {
   ArrowDownRight,
   ArrowDownRight,
   ArrowUpRight,
   ArrowUpRight,
   BarChart3,
   BarChart3,
+  ChevronLeft,
+  ChevronRight,
   Coins,
   Coins,
   Database,
   Database,
   Hash,
   Hash,
@@ -15,7 +17,11 @@ import {
 } from "lucide-react";
 } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useRef, useState } from "react";
 import { useCallback, useEffect, useRef, useState } from "react";
-import { getMyStatsSummary, type MyStatsSummary } from "@/actions/my-usage";
+import {
+  getMyStatsSummary,
+  type ModelBreakdownItem,
+  type MyStatsSummary,
+} from "@/actions/my-usage";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -110,9 +116,27 @@ export function StatisticsSummaryCard({
     setDateRange(range);
     setDateRange(range);
   }, []);
   }, []);
 
 
+  const [breakdownPage, setBreakdownPage] = useState(1);
+
+  // Reset breakdown page when date range changes
+  // biome-ignore lint/correctness/useExhaustiveDependencies: deps used as reset trigger on date range change
+  useEffect(() => {
+    setBreakdownPage(1);
+  }, [dateRange.startDate, dateRange.endDate]);
+
   const isLoading = loading || refreshing;
   const isLoading = loading || refreshing;
   const currencyCode = stats?.currencyCode ?? "USD";
   const currencyCode = stats?.currencyCode ?? "USD";
 
 
+  const maxBreakdownLen = Math.max(
+    stats?.keyModelBreakdown.length ?? 0,
+    stats?.userModelBreakdown.length ?? 0
+  );
+  const breakdownTotalPages = Math.ceil(maxBreakdownLen / MODEL_BREAKDOWN_PAGE_SIZE);
+  const sliceStart = (breakdownPage - 1) * MODEL_BREAKDOWN_PAGE_SIZE;
+  const sliceEnd = breakdownPage * MODEL_BREAKDOWN_PAGE_SIZE;
+  const keyPageItems = stats?.keyModelBreakdown.slice(sliceStart, sliceEnd) ?? [];
+  const userPageItems = stats?.userModelBreakdown.slice(sliceStart, sliceEnd) ?? [];
+
   return (
   return (
     <Card className={className}>
     <Card className={className}>
       <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between space-y-0 pb-4">
       <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between space-y-0 pb-4">
@@ -220,60 +244,71 @@ export function StatisticsSummaryCard({
             <div className="space-y-3">
             <div className="space-y-3">
               <p className="text-sm font-medium text-muted-foreground">{t("modelBreakdown")}</p>
               <p className="text-sm font-medium text-muted-foreground">{t("modelBreakdown")}</p>
               <div className="grid gap-4 md:grid-cols-2">
               <div className="grid gap-4 md:grid-cols-2">
-                {/* Key Stats */}
                 <div className="space-y-2">
                 <div className="space-y-2">
                   <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
                   <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
                     {t("keyStats")}
                     {t("keyStats")}
                   </p>
                   </p>
-                  {stats.keyModelBreakdown.length > 0 ? (
-                    <div className="space-y-2">
-                      {stats.keyModelBreakdown.map((item, index) => (
-                        <ModelBreakdownRow
-                          key={`key-${item.model ?? "unknown"}-${index}`}
-                          model={item.model}
-                          requests={item.requests}
-                          cost={item.cost}
-                          inputTokens={item.inputTokens}
-                          outputTokens={item.outputTokens}
-                          cacheCreationTokens={item.cacheCreationTokens}
-                          cacheReadTokens={item.cacheReadTokens}
-                          currencyCode={currencyCode}
-                          totalCost={stats.totalCost}
-                        />
-                      ))}
-                    </div>
+                  {keyPageItems.length > 0 ? (
+                    <ModelBreakdownColumn
+                      pageItems={keyPageItems}
+                      currencyCode={currencyCode}
+                      totalCost={stats.totalCost}
+                      keyPrefix="key"
+                      pageOffset={sliceStart}
+                    />
                   ) : (
                   ) : (
-                    <p className="text-sm text-muted-foreground py-2">{t("noData")}</p>
+                    <p className="text-sm text-muted-foreground">{t("noData")}</p>
                   )}
                   )}
                 </div>
                 </div>
 
 
-                {/* User Stats */}
                 <div className="space-y-2">
                 <div className="space-y-2">
                   <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
                   <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
                     {t("userStats")}
                     {t("userStats")}
                   </p>
                   </p>
-                  {stats.userModelBreakdown.length > 0 ? (
-                    <div className="space-y-2">
-                      {stats.userModelBreakdown.map((item, index) => (
-                        <ModelBreakdownRow
-                          key={`user-${item.model ?? "unknown"}-${index}`}
-                          model={item.model}
-                          requests={item.requests}
-                          cost={item.cost}
-                          inputTokens={item.inputTokens}
-                          outputTokens={item.outputTokens}
-                          cacheCreationTokens={item.cacheCreationTokens}
-                          cacheReadTokens={item.cacheReadTokens}
-                          currencyCode={currencyCode}
-                          totalCost={stats.totalCost}
-                        />
-                      ))}
-                    </div>
+                  {userPageItems.length > 0 ? (
+                    <ModelBreakdownColumn
+                      pageItems={userPageItems}
+                      currencyCode={currencyCode}
+                      totalCost={stats.totalCost}
+                      keyPrefix="user"
+                      pageOffset={sliceStart}
+                    />
                   ) : (
                   ) : (
-                    <p className="text-sm text-muted-foreground py-2">{t("noData")}</p>
+                    <p className="text-sm text-muted-foreground">{t("noData")}</p>
                   )}
                   )}
                 </div>
                 </div>
               </div>
               </div>
+
+              {breakdownTotalPages > 1 && (
+                <div className="flex items-center justify-between pt-1">
+                  <Button
+                    size="icon"
+                    variant="ghost"
+                    className="h-7 w-7"
+                    aria-label={t("breakdownPrevPage")}
+                    disabled={breakdownPage <= 1}
+                    onClick={() => setBreakdownPage((p) => p - 1)}
+                  >
+                    <ChevronLeft className="h-4 w-4" />
+                  </Button>
+                  <span className="text-xs text-muted-foreground">
+                    {t("breakdownPageIndicator", {
+                      current: breakdownPage,
+                      total: breakdownTotalPages,
+                    })}
+                  </span>
+                  <Button
+                    size="icon"
+                    variant="ghost"
+                    className="h-7 w-7"
+                    aria-label={t("breakdownNextPage")}
+                    disabled={breakdownPage >= breakdownTotalPages}
+                    onClick={() => setBreakdownPage((p) => p + 1)}
+                  >
+                    <ChevronRight className="h-4 w-4" />
+                  </Button>
+                </div>
+              )}
             </div>
             </div>
           </>
           </>
         ) : (
         ) : (
@@ -284,6 +319,43 @@ export function StatisticsSummaryCard({
   );
   );
 }
 }
 
 
+const MODEL_BREAKDOWN_PAGE_SIZE = 5;
+
+interface ModelBreakdownColumnProps {
+  pageItems: ModelBreakdownItem[];
+  currencyCode: CurrencyCode;
+  totalCost: number;
+  keyPrefix: string;
+  pageOffset: number;
+}
+
+function ModelBreakdownColumn({
+  pageItems,
+  currencyCode,
+  totalCost,
+  keyPrefix,
+  pageOffset,
+}: ModelBreakdownColumnProps) {
+  return (
+    <div className="space-y-2">
+      {pageItems.map((item, index) => (
+        <ModelBreakdownRow
+          key={`${keyPrefix}-${item.model ?? "unknown"}-${pageOffset + index}`}
+          model={item.model}
+          requests={item.requests}
+          cost={item.cost}
+          inputTokens={item.inputTokens}
+          outputTokens={item.outputTokens}
+          cacheCreationTokens={item.cacheCreationTokens}
+          cacheReadTokens={item.cacheReadTokens}
+          currencyCode={currencyCode}
+          totalCost={totalCost}
+        />
+      ))}
+    </div>
+  );
+}
+
 interface ModelBreakdownRowProps {
 interface ModelBreakdownRowProps {
   model: string | null;
   model: string | null;
   requests: number;
   requests: number;