From e8a66cf18049f04f6bb5d313f94b76f24fcad93d Mon Sep 17 00:00:00 2001 From: shangzy Date: Tue, 28 Apr 2026 13:40:45 +0800 Subject: [PATCH] Add sorting to detail breakdown tables --- app/detail/[...slug]/page.tsx | 51 +++++++++++++++++++++++++++++------ lib/detail-sort.test.ts | 23 ++++++++++++++++ lib/detail-sort.ts | 20 ++++++++++++++ 3 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 lib/detail-sort.test.ts create mode 100644 lib/detail-sort.ts diff --git a/app/detail/[...slug]/page.tsx b/app/detail/[...slug]/page.tsx index ce74c51..35164b9 100644 --- a/app/detail/[...slug]/page.tsx +++ b/app/detail/[...slug]/page.tsx @@ -3,12 +3,13 @@ import { useEffect, useState, useCallback, startTransition } from "react"; import { useParams } from "next/navigation"; import { motion } from "motion/react"; -import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen } from "lucide-react"; +import { ArrowLeft, Hash, Zap, DollarSign, MessageSquare, DatabaseZap, BookOpen, ArrowUpDown, ArrowDown, ArrowUp } from "lucide-react"; import Link from "next/link"; import { StatsCard } from "@/components/StatsCard"; import { TimeRangeSelector } from "@/components/TimeRangeSelector"; import { TrendChart } from "@/components/charts/TrendChart"; import { buildQuery, formatNumber, formatTokens, formatUSD } from "@/lib/utils"; +import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "@/lib/detail-sort"; import { useTimeRange } from "@/lib/time-range-context"; import { useI18n } from "@/lib/i18n"; @@ -16,8 +17,8 @@ interface DetailData { calls: number; prompt_tokens: number; completion_tokens: number; cache_creation_tokens: number; cache_read_tokens: number; total_tokens: number; quota: number; display_name?: string; - models?: { name: string; calls: number; total_tokens: number; quota: number }[]; - users?: { name: string; calls: number; total_tokens: number; quota: number }[]; + models?: DetailBreakdownItem[]; + users?: DetailBreakdownItem[]; channel_name?: string; } @@ -33,6 +34,8 @@ export default function DetailPage() { const [data, setData] = useState(null); const [trends, setTrends] = useState<{ date: string; calls: number; total_tokens: number; prompt_tokens: number; completion_tokens: number }[]>([]); const [loading, setLoading] = useState(true); + const [breakdownSortKey, setBreakdownSortKey] = useState("total_tokens"); + const [breakdownSortAsc, setBreakdownSortAsc] = useState(false); const fetchData = useCallback(async () => { startTransition(() => setLoading(true)); @@ -54,8 +57,28 @@ export default function DetailPage() { const title = type === "channel" ? (data?.channel_name || decodedId) : (data?.display_name || decodedId); const typeLabel = { user: t("detail.user"), model: t("detail.model"), channel: t("detail.channel") }[type] || type; const breakdownItems = data?.models || data?.users || []; + const sortedBreakdownItems = sortDetailBreakdown(breakdownItems, breakdownSortKey, breakdownSortAsc); const breakdownLabel = data?.models ? t("detail.modelDist") : t("detail.userDist"); + function handleBreakdownSort(key: DetailBreakdownSortKey) { + if (breakdownSortKey === key) setBreakdownSortAsc(!breakdownSortAsc); + else { setBreakdownSortKey(key); setBreakdownSortAsc(false); } + } + + const renderBreakdownSortIcon = (col: DetailBreakdownSortKey) => { + if (breakdownSortKey !== col) return ; + return breakdownSortAsc + ? + : ; + }; + + const breakdownColumns: { key: DetailBreakdownSortKey | null; label: string; align: "left" | "right" }[] = [ + { key: null, label: t("th.name"), align: "left" }, + { key: "calls", label: t("th.calls"), align: "right" }, + { key: "total_tokens", label: t("th.totalToken"), align: "right" }, + { key: "quota", label: t("th.cost"), align: "right" }, + ]; + return (
@@ -95,14 +118,26 @@ export default function DetailPage() { - - - - + {breakdownColumns.map((col) => { + const key = col.key; + return ( + + ); + })} - {breakdownItems.map((item) => ( + {sortedBreakdownItems.map((item) => ( diff --git a/lib/detail-sort.test.ts b/lib/detail-sort.test.ts new file mode 100644 index 0000000..5f23dca --- /dev/null +++ b/lib/detail-sort.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { sortDetailBreakdown } from "./detail-sort"; + +describe("sortDetailBreakdown", () => { + const rows = [ + { name: "beta", calls: 2, total_tokens: 100, quota: 500 }, + { name: "alpha", calls: 5, total_tokens: 100, quota: 300 }, + { name: "gamma", calls: 1, total_tokens: 300, quota: 100 }, + ]; + + test("sorts by the selected numeric field without mutating the source rows", () => { + const sorted = sortDetailBreakdown(rows, "quota", false); + + expect(sorted.map((row) => row.name)).toEqual(["beta", "alpha", "gamma"]); + expect(rows.map((row) => row.name)).toEqual(["beta", "alpha", "gamma"]); + }); + + test("uses name as a stable tie-breaker", () => { + const sorted = sortDetailBreakdown(rows, "total_tokens", false); + + expect(sorted.map((row) => row.name)).toEqual(["gamma", "alpha", "beta"]); + }); +}); diff --git a/lib/detail-sort.ts b/lib/detail-sort.ts new file mode 100644 index 0000000..36fd0f9 --- /dev/null +++ b/lib/detail-sort.ts @@ -0,0 +1,20 @@ +export interface DetailBreakdownItem { + name: string; + calls: number; + total_tokens: number; + quota: number; +} + +export type DetailBreakdownSortKey = "calls" | "total_tokens" | "quota"; + +export function sortDetailBreakdown( + items: DetailBreakdownItem[], + sortKey: DetailBreakdownSortKey, + sortAsc: boolean +): DetailBreakdownItem[] { + return [...items].sort((a, b) => { + const diff = a[sortKey] - b[sortKey]; + if (diff !== 0) return sortAsc ? diff : -diff; + return a.name.localeCompare(b.name); + }); +}
{t("th.name")}{t("th.calls")}{t("th.totalToken")}{t("th.cost")} handleBreakdownSort(key) : undefined} + > + {key ? ( + + {col.label} {renderBreakdownSortIcon(key)} + + ) : col.label} +
{item.name} {formatNumber(item.calls)}