SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
4.6 KB · 74 lines tsx
Raw Blame History
1import Link from 'next/link';2import type { PortfolioSummary, ValuedItem } from '@/lib/account/portfolio';3import type { Display } from '@/lib/account/display';4import { Badge, Card, CardHeader, Delta, Stat } from '@/components/ui/primitives';5import { Donut, Bars } from './charts';6import { cn, confidenceLabel, fmtPct } from '@/lib/format';78export function SummaryStats({ s, d, className }: { s: PortfolioSummary; d: Display; className?: string }) {9  return (10    <div className={cn('grid grid-cols-2 gap-4 sm:grid-cols-4', className)}>11      <Stat label="Collection value" value={s.valuedCount ? d.money(s.valueUsd) : '—'} sub={s.valuedCount ? <>{s.valuedCount}/{s.itemCount} items valued · confidence {confidenceLabel(s.confidence)}</> : `${s.itemCount} item${s.itemCount === 1 ? '' : 's'} · no valuation yet`} />12      <Stat label="Cost basis" value={s.costBasisUsd > 0 ? d.money(s.costBasisUsd) : '—'} sub={s.costBasisUsd > 0 ? 'purchase prices at acquisition-date FX' : 'add purchase prices to track returns'} />13      <Stat label="Unrealized gain" value={s.gainUsd === null ? '—' : <span className={s.gainUsd >= 0 ? 'text-gain' : 'text-loss'}>{d.money(s.gainUsd)}</span>} sub={s.gainUsd === null ? 'needs value + cost' : 'items with both value and cost'} />14      <Stat label="Return" value={<Delta value={s.returnPct} className="text-lg" />} sub={s.manualValueUsd > 0 ? `${d.money(s.manualValueUsd)} from manual values` : 'vs cost basis'} />15    </div>16  );17}1819export function AllocationCards({ s, d }: { s: PortfolioSummary; d: Display }) {20  return (21    <div className="grid gap-4 lg:grid-cols-3">22      <Card>23        <CardHeader title="Allocation by category" subtitle="Share of valued items" />24        <div className="p-4">25          <Donut data={s.allocationByCategory.map((b) => ({ label: b.label, value: b.valueUsd }))} centerValue={s.allocationByCategory.length ? String(s.allocationByCategory.length) : undefined} centerLabel="categories" />26        </div>27      </Card>28      <Card>29        <CardHeader title="Concentration & mix" subtitle="Where the risk sits" />30        <dl className="grid grid-cols-2 gap-y-2 p-4 text-xs">31          <dt className="text-subtle">Largest position</dt>32          <dd className="num text-right">{fmtPct(s.concentration.topItemShare, 0, false)}</dd>33          <dt className="text-subtle">Top 5 positions</dt>34          <dd className="num text-right">{fmtPct(s.concentration.top5Share, 0, false)}</dd>35          <dt className="text-subtle">HHI (0–1)</dt>36          <dd className="num text-right">{s.concentration.hhi === null ? '—' : s.concentration.hhi.toFixed(2)}</dd>37          <dt className="text-subtle">Graded share</dt>38          <dd className="num text-right">{fmtPct(s.gradingMix.filter((b) => b.key !== 'raw').reduce((a, b) => a + b.share, 0), 0, false)}</dd>39          <dt className="text-subtle">High-liquidity share</dt>40          <dd className="num text-right">{fmtPct(s.liquidityMix.find((b) => b.key === 'high')?.share ?? 0, 0, false)}</dd>41          <dt className="text-subtle">High-rarity share</dt>42          <dd className="num text-right">{fmtPct(s.rarityMix.find((b) => b.key === 'high')?.share ?? 0, 0, false)}</dd>43        </dl>44      </Card>45      <Card>46        <CardHeader title="Best & worst" subtitle="By return on cost" />47        <div className="p-4">48          {s.best.length ? (49            <Bars data={[...s.best.slice(0, 3).map((i) => ({ label: i.title, value: (i.gainPct ?? 0) * 100, tone: 'gain' as const })), ...s.worst.slice(0, 3).map((i) => ({ label: i.title, value: (i.gainPct ?? 0) * 100, tone: 'loss' as const }))]} format={(v) => `${v > 0 ? '+' : ''}${v.toFixed(0)}%`} />50          ) : (51            <p className="text-xs text-subtle">Add purchase prices to see performers.</p>52          )}53          <p className="mt-3 text-[11px] text-subtle">Values in {d.currency}; RIV estimates carry a confidence level.</p>54        </div>55      </Card>56    </div>57  );58}5960export function ValueSourceBadge({ item }: { item: ValuedItem }) {61  if (item.valueSource === 'variant_riv') return <Badge tone="index">RIV · {item.grader ? `${item.grader.toUpperCase()} ${item.grade ?? ''}`.trim() : 'variant'}</Badge>;62  if (item.valueSource === 'asset_riv') return <Badge tone="neutral">RIV · asset</Badge>;63  if (item.valueSource === 'manual') return <Badge tone="alert">manual</Badge>;64  return <Badge tone="neutral">no valuation</Badge>;65}6667export function AssetLink({ slug, title, className }: { slug: string; title: string; className?: string }) {68  return (69    <Link href={`/asset/${slug}`} className={cn('hover:underline', className)}>70      {title}71    </Link>72  );73}74