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%
10.8 KB · 199 lines tsx
Raw Blame History
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, Badge } 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 { Thumb } from '@/components/market/bits';12import { CompareForm, ShareButton } from './compare-form';1314export const metadata: Metadata = { title: 'Compare', description: 'Compare up to four collectible assets or RareIndex indices side by side: indexed performance and key metrics.' };1516const SLOTS = ['a', 'b', 'c', 'd'] as const;17const SERIES = ['var(--ri-series-1, #2f5bd6)', 'var(--ri-series-2, #c2410c)', 'var(--ri-series-3, #0e9384)', 'var(--ri-series-4, #7c3aed)'];18const WINDOWS: Array<{ id: string; days: number; label: string }> = [19  { id: '3m', days: 92, label: '3M' },20  { id: '1y', days: 366, label: '1Y' },21  { id: '2y', days: 731, label: '2Y' },22  { id: '5y', days: 1827, label: '5Y' },23];2425type Column = { key: string; label: string; short: string; href: string; color: string; kind: 'asset' | 'index'; image?: string | null; rows: Array<[string, React.ReactNode]> };2627export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {28  const sp = await searchParams;29  const keys = SLOTS.map((k) => (Array.isArray(sp[k]) ? sp[k]![0] : sp[k])).filter((v): v is string => Boolean(v));30  const win = WINDOWS.find((w) => w.id === (Array.isArray(sp.w) ? sp.w[0] : sp.w)) ?? WINDOWS[2]!;31  const assetSlugs = keys.filter((k) => !k.toUpperCase().startsWith('RARE'));32  const tickers = keys.filter((k) => k.toUpperCase().startsWith('RARE'));33  const [assets, indicesRaw] = await Promise.all([getAssetsBySlugs(assetSlugs), Promise.all(tickers.map((t) => getIndex(t)))]);34  const indices = indicesRaw.filter((i): i is NonNullable<typeof i> => Boolean(i));35  // keep the URL order so colours follow the entity, never the rank36  const ordered = keys.map((k) => assets.find((a) => a.slug === k) ?? indices.find((i) => i.ticker.toUpperCase() === k.toUpperCase()) ?? null).filter((x): x is NonNullable<typeof x> => Boolean(x));3738  const series = await Promise.all(39    ordered.map(async (item, i) => {40      if ('slug' in item) {41        const pts = (await getAssetSnapshots(item.id, '', win.days)).filter((p) => p.rivUsd != null).map((p) => ({ x: p.date, y: p.rivUsd! }));42        return { id: item.slug, label: item.title.length > 34 ? `${item.title.slice(0, 33)}…` : item.title, color: SERIES[i % 4], points: pts };43      }44      const pts = (await getIndexSeries(item.id, win.days)).map((p) => ({ x: p.date, y: p.value }));45      return { id: item.ticker, label: item.ticker, dashed: true, color: SERIES[i % 4], points: pts };46    }),47  );4849  const columns: Column[] = ordered.map((item, i) => {50    if ('slug' in item) {51      const a = item;52      return {53        key: a.slug,54        label: a.title,55        short: a.title.length > 28 ? `${a.title.slice(0, 27)}…` : a.title,56        href: `/asset/${a.slug}`,57        color: SERIES[i % 4]!,58        kind: 'asset',59        image: a.heroImageUrl,60        rows: [61          ['Category', catName(a.categorySlug)],62          ['RIV', a.rivUsd === null ? <span className="text-subtle">—</span> : <span className="font-semibold">{fmtMoney(a.rivUsd)}</span>],63          ['Confidence', a.rivSampleSize ? `${confidenceLabel(a.rivConfidence)} · n=${a.rivSampleSize}` : <span className="text-subtle">—</span>],64          ['1D', <Delta key="d1" value={a.change1d} />],65          ['7D', <Delta key="d7" value={a.change7d} />],66          ['30D', <Delta key="d30" value={a.change30d} />],67          ['1Y', <Delta key="d1y" value={a.change1y} />],68          ['Latest sale', a.latestSaleUsd === null ? <span className="text-subtle">—</span> : `${fmtMoney(a.latestSaleUsd)} · ${fmtRelative(a.latestSaleAt)}`],69          ['Sales 30D / all', `${fmtNum(a.sales30d)} / ${fmtNum(a.salesCount)}`],70          ['Active listings', fmtNum(a.activeListings)],71          ['Liquidity', a.liquidityScore == null ? <span className="text-subtle">—</span> : `${Math.round(a.liquidityScore)}/100`],72          ['Rarity', a.rarityScore == null ? <span className="text-subtle">—</span> : `${Math.round(a.rarityScore)}/100`],73          ['Trending', a.trendingScore == null ? <span className="text-subtle">—</span> : a.trendingScore.toFixed(1)],74        ],75      };76    }77    const ix = item;78    return {79      key: ix.ticker,80      label: ix.name,81      short: ix.ticker,82      href: `/rareindex/${ix.ticker}`,83      color: SERIES[i % 4]!,84      kind: 'index',85      rows: [86        ['Category', 'Index'],87        ['Value', ix.latest ? <span className="font-semibold">{ix.latest.value.toFixed(2)}</span> : <Badge>building</Badge>],88        ['Coverage', ix.latest?.coverage != null ? `${Math.round(ix.latest.coverage * 100)}%` : <span className="text-subtle">—</span>],89        ['1D', <Delta key="d1" value={ix.change1d} />],90        ['7D', <Delta key="d7" value={ix.change7d} />],91        ['30D', <Delta key="d30" value={ix.change30d} />],92        ['1Y', <Delta key="d1y" value={ix.change1y} />],93        ['Median sale', ix.latest?.medianSaleUsd != null ? fmtMoney(ix.latest.medianSaleUsd) : <span className="text-subtle">—</span>],94        ['Transactions', ix.latest ? fmtNum(ix.latest.transactions) : <span className="text-subtle">—</span>],95        ['Constituents', ix.latest ? fmtNum(ix.latest.constituentsCount) : <span className="text-subtle">—</span>],96        ['Liquidity', ix.latest?.liquidityScore != null ? `${Math.round(ix.latest.liquidityScore)}/100` : <span className="text-subtle">—</span>],97        ['Rarity', <span key="r" className="text-subtle">—</span>],98        ['Momentum', ix.latest?.momentum != null ? ix.latest.momentum.toFixed(2) : <span className="text-subtle">—</span>],99      ],100    };101  });102103  const qs = (w: string) => {104    const p = new URLSearchParams();105    keys.forEach((k, i) => p.set(SLOTS[i]!, k));106    p.set('w', w);107    return `/compare?${p.toString()}`;108  };109  const metricLabels = columns[0]?.rows.map((r) => r[0]) ?? [];110111  return (112    <div>113      <PageHeader title="Compare" description="Up to four assets or indices. Performance is rebased to 0% at the start of the window using each item's daily RIV or index value; metrics sit side by side with their evidence." compact actions={keys.length ? <ShareButton /> : null} />114      <CompareForm initial={keys} />115      {columns.length ? (116        <div className="mt-4 grid gap-4">117          <Card className="p-3 sm:p-4">118            <div className="flex flex-wrap items-center justify-between gap-2">119              <h2 className="text-sm font-semibold">Indexed performance</h2>120              <div className="inline-flex rounded-md border border-border bg-sunken p-0.5 text-[11px] font-medium" role="group" aria-label="Window">121                {WINDOWS.map((w) => (122                  <Link key={w.id} href={qs(w.id)} scroll={false} className={cn('num h-7 rounded-[4px] px-2.5 leading-7', w.id === win.id ? 'bg-elevated text-fg shadow-card' : 'text-muted hover:text-fg')} aria-current={w.id === win.id ? 'true' : undefined}>123                    {w.label}124                  </Link>125                ))}126              </div>127            </div>128            <LineChart ariaLabel="Indexed performance comparison" height={300} indexed className="mt-2" series={series} emptyLabel="No daily history yet for the selected items in this window" />129          </Card>130131          {/* desktop: matrix */}132          <Card className="hidden overflow-hidden md:block">133            <CardHeader title="Key metrics" subtitle="Every estimate carries its evidence; “—” means data unavailable, never zero." />134            <Table>135              <thead>136                <tr>137                  <th className={th}>Metric</th>138                  {columns.map((c) => (139                    <th key={c.key} className={cn(th, 'text-right')}>140                      <span className="inline-flex items-center gap-1.5">141                        <span className="inline-block h-2 w-2 rounded-full" style={{ background: c.color }} />142                        <Link href={c.href} className="hover:underline">143                          {c.short}144                        </Link>145                      </span>146                    </th>147                  ))}148                </tr>149              </thead>150              <tbody>151                {metricLabels.map((label, ri) => (152                  <tr key={label} className="hover:bg-sunken">153                    <td className={cn(td, 'text-muted')}>{label}</td>154                    {columns.map((c) => (155                      <td key={c.key} className={tdNum}>156                        {c.rows[ri]?.[1] ?? <span className="text-subtle">—</span>}157                      </td>158                    ))}159                  </tr>160                ))}161              </tbody>162            </Table>163          </Card>164165          {/* phone: one card per item, swipeable */}166          <div className="md:hidden">167            <h2 className="mb-2 text-sm font-semibold">Key metrics</h2>168            <div className="scrollbar-none -mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-1">169              {columns.map((c) => (170                <article key={c.key} className="card w-[86vw] max-w-sm shrink-0 snap-center overflow-hidden">171                  <header className="flex items-center gap-2.5 border-b border-border px-3 py-2.5" style={{ boxShadow: `inset 3px 0 0 ${c.color}` }}>172                    {c.image !== undefined ? <Thumb src={c.image} alt="" size={36} rounded="rounded-md" /> : null}173                    <Link href={c.href} className="min-w-0 flex-1 truncate text-[13px] font-semibold text-fg">174                      {c.label}175                    </Link>176                  </header>177                  <dl className="divide-y divide-border text-[12px]">178                    {c.rows.map(([k, v]) => (179                      <div key={k} className="flex items-center justify-between gap-3 px-3 py-1.5">180                        <dt className="text-muted">{k}</dt>181                        <dd className="num text-right text-fg">{v}</dd>182                      </div>183                    ))}184                  </dl>185                </article>186              ))}187            </div>188            <p className="mt-1 text-center text-[10px] text-subtle">Swipe to compare</p>189          </div>190        </div>191      ) : (192        <Card className="mt-4">193          <EmptyState title="Pick items to compare" description="Search an asset name or use index tickers such as RARE, RARE-TCG, RARE-WATCH. Up to four items; the URL is shareable." />194        </Card>195      )}196    </div>197  );198}199