diff --git a/package.json b/package.json
index 87a692f..950cec5 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
- "test": "node test/ccusage.test.mts"
+ "test": "node test/ccusage.test.mts && for f in test/*.test.mts; do [ \"$f\" = \"test/ccusage.test.mts\" ] || node \"$f\" || exit 1; done"
},
"dependencies": {
"@auth/core": "^0.40.0",
diff --git a/src/app/profile/[username]/page.tsx b/src/app/profile/[username]/page.tsx
index 94a7aa5..ce1f00d 100644
--- a/src/app/profile/[username]/page.tsx
+++ b/src/app/profile/[username]/page.tsx
@@ -21,6 +21,7 @@ import Footer from "@/components/Footer";
import NavBar from "@/components/NavBar";
import TierBadge from "@/components/TierBadge";
import OpenToWorkToggle from "@/components/OpenToWorkToggle";
+import ProfileModelList from "@/components/ProfileModelList";
// ISR the full profile page too — direct loads and sheet "view full profile"
// both get cached HTML; data is at most 2 minutes stale.
@@ -156,8 +157,6 @@ export default async function ProfilePage({ params }: ProfileParams) {
);
const modelTotal = modelEntries.reduce((s, [, v]) => s + v, 0) || 1;
const topModels = modelEntries.slice(0, MODEL_ROWS);
- const otherModels = modelEntries.slice(MODEL_ROWS);
- const otherValue = otherModels.reduce((s, [, v]) => s + v, 0);
// Days each tool was active (cost attribution per tool isn't reliable when
// a day mixes tools, so day counts are the honest stat).
@@ -365,38 +364,7 @@ export default async function ProfilePage({ params }: ProfileParams) {
{hasModelCosts ? "by cost" : "by days used"}
-
- {topModels.map(([name, value]) => (
-
-
- {name}
-
- {hasModelCosts ? `$${formatCurrency(value)}` : `${value}d`}
- · {Math.round((value / modelTotal) * 100)}%
-
-
-
-
- ))}
- {otherValue > 0 && (
-
- +{otherModels.length} more models
-
- {hasModelCosts ? `$${formatCurrency(otherValue)}` : `${otherValue}d`}
-
-
- )}
- {!hasModelCosts && (
-
- Cost split per model appears after the next npx viberank-cli submission.
-
- )}
-
+
)}
diff --git a/src/components/ProfileModelList.tsx b/src/components/ProfileModelList.tsx
new file mode 100644
index 0000000..8536bdf
--- /dev/null
+++ b/src/components/ProfileModelList.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useState } from "react";
+import { ChevronDown, ChevronUp } from "lucide-react";
+import { formatCurrency } from "@/lib/utils";
+import { splitProfileModelRows, type ProfileModelEntry } from "@/lib/profile-models";
+
+interface ProfileModelListProps {
+ entries: ProfileModelEntry[];
+ modelTotal: number;
+ hasModelCosts: boolean;
+ initialLimit?: number;
+}
+
+export default function ProfileModelList({
+ entries,
+ modelTotal,
+ hasModelCosts,
+ initialLimit = 8,
+}: ProfileModelListProps) {
+ const [expanded, setExpanded] = useState(false);
+ const rows = splitProfileModelRows(entries, { limit: initialLimit, expanded });
+ const total = modelTotal || 1;
+
+ return (
+
+ {rows.visible.map(([name, value]) => (
+
+
+ {name}
+
+ {hasModelCosts ? `$${formatCurrency(value)}` : `${value}d`}
+ · {Math.round((value / total) * 100)}%
+
+
+
+
+ ))}
+
+ {rows.canExpand && (
+
+
+ {!expanded && rows.hiddenTotal > 0 && (
+
+ {hasModelCosts ? `$${formatCurrency(rows.hiddenTotal)}` : `${rows.hiddenTotal}d`}
+
+ )}
+
+ )}
+
+ {!hasModelCosts && (
+
+ Cost split per model appears after the next npx viberank-cli submission.
+
+ )}
+
+ );
+}
diff --git a/src/lib/profile-models.ts b/src/lib/profile-models.ts
new file mode 100644
index 0000000..43e7dc4
--- /dev/null
+++ b/src/lib/profile-models.ts
@@ -0,0 +1,25 @@
+export type ProfileModelEntry = readonly [string, number];
+
+export interface ProfileModelRows {
+ visible: ProfileModelEntry[];
+ hidden: ProfileModelEntry[];
+ hiddenTotal: number;
+ canExpand: boolean;
+}
+
+export function splitProfileModelRows(
+ entries: ProfileModelEntry[],
+ options: { limit: number; expanded: boolean }
+): ProfileModelRows {
+ const limit = Math.max(0, Math.floor(options.limit));
+ const canExpand = entries.length > limit;
+ const visible = options.expanded || !canExpand ? entries : entries.slice(0, limit);
+ const hidden = options.expanded || !canExpand ? [] : entries.slice(limit);
+
+ return {
+ visible,
+ hidden,
+ hiddenTotal: hidden.reduce((sum, [, value]) => sum + value, 0),
+ canExpand,
+ };
+}
diff --git a/test/profile-models.test.mts b/test/profile-models.test.mts
new file mode 100644
index 0000000..f23ba62
--- /dev/null
+++ b/test/profile-models.test.mts
@@ -0,0 +1,48 @@
+/**
+ * Focused tests for profile model-list presentation.
+ * Run: node test/profile-models.test.mts
+ */
+
+const { splitProfileModelRows } = await import("../src/lib/profile-models.ts");
+
+let passed = 0;
+let failed = 0;
+
+function ok(name: string, cond: boolean, detail = "") {
+ if (cond) {
+ passed++;
+ console.log(` ✓ ${name}`);
+ } else {
+ failed++;
+ console.log(` ✗ ${name} ${detail}`);
+ }
+}
+
+console.log("\n[1] Profile model list can expand beyond the default rows");
+{
+ const entries = Array.from({ length: 11 }, (_, index) => [`model-${index + 1}`, 11 - index] as [string, number]);
+
+ const collapsed = splitProfileModelRows(entries, { limit: 8, expanded: false });
+ ok("shows only the default row count when collapsed", collapsed.visible.length === 8, `got ${collapsed.visible.length}`);
+ ok("keeps hidden rows accessible", collapsed.hidden.length === 3, `got ${collapsed.hidden.length}`);
+ ok("marks list as expandable", collapsed.canExpand);
+ ok("sums hidden row values", collapsed.hiddenTotal === 6, `got ${collapsed.hiddenTotal}`);
+
+ const expanded = splitProfileModelRows(entries, { limit: 8, expanded: true });
+ ok("shows every row when expanded", expanded.visible.length === 11, `got ${expanded.visible.length}`);
+ ok("hides no rows after expansion", expanded.hidden.length === 0, `got ${expanded.hidden.length}`);
+ ok("still marks list as collapsible", expanded.canExpand);
+}
+
+console.log("\n[2] Short model lists do not render expansion controls");
+{
+ const entries: [string, number][] = [["opus", 3], ["sonnet", 2]];
+ const rows = splitProfileModelRows(entries, { limit: 8, expanded: false });
+
+ ok("shows all short-list rows", rows.visible.length === 2, `got ${rows.visible.length}`);
+ ok("has no hidden rows", rows.hidden.length === 0, `got ${rows.hidden.length}`);
+ ok("does not mark short list as expandable", !rows.canExpand);
+}
+
+console.log(`\n${failed === 0 ? "✅" : "❌"} ${passed} passed, ${failed} failed\n`);
+process.exit(failed === 0 ? 0 : 1);