SPB Git

spb/rareindex

Public
6.7 KB · 102 lines tsx
1import type { Metadata } from 'next';2import Link from 'next/link';3import { PageHeader } from '@/components/ui/page-header';4import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum } from '@/components/ui/primitives';5import { LineChart } from '@/components/charts/line-chart';6import { getAssetsBySlugs, getAssetSnapshots } from '@/lib/queries/assets';7import { getIndex, getIndexSeries } from '@/lib/queries/indices';8import { fmtMoney, fmtNum, fmtRelative, confidenceLabel, cn } from '@/lib/format';9import { catName } from '@/lib/taxonomy';10import type { SP } from '@/lib/search-params';11import { CompareForm } from './compare-form';1213export const metadata: Metadata = { title: 'Compare', description: 'Compare up to four collectible assets or RareIndex indices side by side: indexed performance and key metrics.' };1415const SLOTS = ['a', 'b', 'c', 'd'] as const;1617export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {18  const sp = await searchParams;19  const keys = SLOTS.map((k) => (Array.isArray(sp[k]) ? sp[k]![0] : sp[k])).filter((v): v is string => Boolean(v));20  const assetSlugs = keys.filter((k) => !k.toUpperCase().startsWith('RARE'));21  const tickers = keys.filter((k) => k.toUpperCase().startsWith('RARE'));22  const [assets, indices] = await Promise.all([getAssetsBySlugs(assetSlugs), Promise.all(tickers.map((t) => getIndex(t)))]);23  const series = await Promise.all([24    ...assets.map(async (a) => ({ id: a.slug, label: a.title.length > 40 ? `${a.title.slice(0, 39)}…` : a.title, points: (await getAssetSnapshots(a.id, '', 730)).filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! })) })),25    ...indices.filter((i): i is NonNullable<typeof i> => Boolean(i)).map(async (i) => ({ id: i.ticker, label: i.ticker, dashed: true, points: (await getIndexSeries(i.id, 730)).map((p) => ({ x: p.date, y: p.value })) })),26  ]);27  const found = assets.length + indices.filter(Boolean).length;28  return (29    <div>30      <PageHeader title="Compare" description="Up to four assets or indices. Performance is rebased to 0% at the start of the common window using each item's daily RIV or index value; metrics are shown side by side with their evidence." compact />31      <CompareForm initial={keys} />32      {found ? (33        <div className="mt-4 grid gap-4">34          <Card className="p-4">35            <h2 className="text-sm font-semibold">Indexed performance · 2 years</h2>36            <LineChart ariaLabel="Indexed performance comparison" height={320} indexed className="mt-3" series={series} emptyLabel="No daily history yet for the selected items" />37          </Card>38          <Card className="overflow-hidden">39            <CardHeader title="Key metrics" />40            <Table>41              <thead>42                <tr>43                  <th className={th}>Metric</th>44                  {assets.map((a) => (45                    <th key={a.id} className={cn(th, 'text-right')}>46                      <Link href={`/asset/${a.slug}`} className="hover:underline">47                        {a.title.length > 32 ? `${a.title.slice(0, 31)}…` : a.title}48                      </Link>49                    </th>50                  ))}51                  {indices.filter(Boolean).map((i) => (52                    <th key={i!.ticker} className={cn(th, 'text-right')}>53                      <Link href={`/rareindex/${i!.ticker}`} className="hover:underline">54                        {i!.ticker}55                      </Link>56                    </th>57                  ))}58                </tr>59              </thead>60              <tbody>61                {[62                  ['Category', assets.map((a) => catName(a.categorySlug)), indices.map(() => 'Index')],63                  ['RIV / value', assets.map((a) => (a.rivUsd === null ? '—' : fmtMoney(a.rivUsd))), indices.map((i) => (i?.latest ? i.latest.value.toFixed(2) : 'building'))],64                  ['Confidence', assets.map((a) => `${confidenceLabel(a.rivConfidence)} (n=${a.rivSampleSize})`), indices.map((i) => (i?.latest?.coverage != null ? `coverage ${Math.round(i.latest.coverage * 100)}%` : '—'))],65                  ['1D', assets.map((a) => <Delta key={a.id} value={a.change1d} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change1d} />)],66                  ['7D', assets.map((a) => <Delta key={a.id} value={a.change7d} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change7d} />)],67                  ['30D', assets.map((a) => <Delta key={a.id} value={a.change30d} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change30d} />)],68                  ['1Y', assets.map((a) => <Delta key={a.id} value={a.change1y} />), indices.map((i) => <Delta key={i?.ticker} value={i?.change1y} />)],69                  ['Latest sale', assets.map((a) => (a.latestSaleUsd === null ? '—' : `${fmtMoney(a.latestSaleUsd)} · ${fmtRelative(a.latestSaleAt)}`)), indices.map((i) => (i?.latest?.medianSaleUsd != null ? `median ${fmtMoney(i.latest.medianSaleUsd)}` : '—'))],70                  ['Sales (30D / all)', assets.map((a) => `${fmtNum(a.sales30d)} / ${fmtNum(a.salesCount)}`), indices.map((i) => (i?.latest ? fmtNum(i.latest.transactions) : '—'))],71                  ['Active listings', assets.map((a) => fmtNum(a.activeListings)), indices.map(() => '—')],72                  ['Liquidity', assets.map((a) => (a.liquidityScore == null ? '—' : Math.round(a.liquidityScore))), indices.map((i) => (i?.latest?.liquidityScore != null ? Math.round(i.latest.liquidityScore) : '—'))],73                  ['Rarity', assets.map((a) => (a.rarityScore == null ? '—' : Math.round(a.rarityScore))), indices.map(() => '—')],74                  ['Trending', assets.map((a) => (a.trendingScore == null ? '—' : a.trendingScore.toFixed(1))), indices.map((i) => (i?.latest?.momentum != null ? i.latest.momentum.toFixed(2) : '—'))],75                ].map(([label, av, iv]) => (76                  <tr key={label as string}>77                    <td className={cn(td, 'text-muted')}>{label as string}</td>78                    {(av as React.ReactNode[]).map((v, i) => (79                      <td key={`a${i}`} className={tdNum}>80                        {v}81                      </td>82                    ))}83                    {(iv as React.ReactNode[]).map((v, i) => (84                      <td key={`i${i}`} className={tdNum}>85                        {v}86                      </td>87                    ))}88                  </tr>89                ))}90              </tbody>91            </Table>92          </Card>93        </div>94      ) : (95        <Card className="mt-4">96          <EmptyState title="Pick items to compare" description="Add asset slugs (from any asset URL) or index tickers such as RARE, RARE-TCG, RARE-WATCH." />97        </Card>98      )}99    </div>100  );101}102
Files · d93392862678cbf30e175697fcb812bc4ff0b5a7
Menu

Theme