From 61413761e87a54150d27381c4ec8d42f78186bba Mon Sep 17 00:00:00 2001 From: "B-Etter.Digital" Date: Thu, 18 Jun 2026 20:49:42 +0700 Subject: [PATCH] add expandable profile model list --- package.json | 2 +- src/app/profile/[username]/page.tsx | 36 +-------------- src/components/ProfileModelList.tsx | 71 +++++++++++++++++++++++++++++ src/lib/profile-models.ts | 25 ++++++++++ test/profile-models.test.mts | 48 +++++++++++++++++++ 5 files changed, 147 insertions(+), 35 deletions(-) create mode 100644 src/components/ProfileModelList.tsx create mode 100644 src/lib/profile-models.ts create mode 100644 test/profile-models.test.mts 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);