1import type { Metadata } from 'next';23import Link from 'next/link';4import { readFile } from 'node:fs/promises';5import path from 'node:path';6import { PageHeader } from '@/components/ui/page-header';7import { Card, CardHeader, Table, th, td, tdNum, Badge, EmptyState, Stat } from '@/components/ui/primitives';8import { getSiteStats } from '@/lib/queries/site';9import { getConnectorCoverage, getFxCoverage } from '@/lib/queries/market-lists';10import { getMarketRows } from '@/lib/queries/markets';11import { fmtNum, fmtRelative, cn } from '@/lib/format';12import { catName, humanize } from '@/lib/taxonomy';1314export const metadata: Metadata = { title: 'Data & methodology', description: 'Coverage by category, source and connector; RareIndex methodology; exports and API access.' };15// Coverage aggregates over millions of rows take ~70 s: prerender at build (staticPageGenerationTimeout raised), then hourly ISR.16export const revalidate = 3600;1718async function loadMethodology(): Promise<string> {19 const candidates = [path.resolve(process.cwd(), '../../docs/METHODOLOGY.md'), path.resolve(process.cwd(), 'docs/METHODOLOGY.md'), path.resolve(process.cwd(), '../docs/METHODOLOGY.md')];20 for (const p of candidates) {21 try {22 return await readFile(p, 'utf8');23 } catch {24 /* try next */25 }26 }27 return '';28}2930/** Minimal markdown → JSX for headings, paragraphs and bullet lists (methodology doc is controlled content). */31function Markdown({ text }: { text: string }) {32 const blocks = text.split(/\n{2,}/);33 return (34 <div className="prose-ri space-y-3 text-[13px] leading-relaxed text-muted">35 {blocks.map((b, i) => {36 const t = b.trim();37 if (!t) return null;38 if (t.startsWith('# ')) return <h2 key={i} className="text-lg font-semibold text-fg">{t.slice(2)}</h2>;39 if (t.startsWith('## ')) return <h3 key={i} id={t.slice(3).toLowerCase().replace(/[^a-z0-9]+/g, '-')} className="pt-2 text-sm font-semibold text-fg">{t.slice(3)}</h3>;40 if (t.startsWith('- ')) return <ul key={i} className="list-disc space-y-1 pl-5">{t.split('\n').map((l, j) => <li key={j} dangerouslySetInnerHTML={{ __html: inline(l.replace(/^- /, '')) }} />)}</ul>;41 return <p key={i} dangerouslySetInnerHTML={{ __html: inline(t.replace(/\n/g, ' ')) }} />;42 })}43 </div>44 );45}46function inline(s: string): string {47 return s.replace(/&/g, '&').replace(/</g, '<').replace(/\*\*(.+?)\*\*/g, '<strong class="text-fg">$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/`(.+?)`/g, '<code class="rounded-sm bg-inset px-1">$1</code>');48}4950export default async function DataPage() {51 const [stats, connectors, fx, rows, md] = await Promise.all([getSiteStats(), getConnectorCoverage(), getFxCoverage(), getMarketRows(), loadMethodology()]);52 const active = rows.filter((r) => r.counts.assets > 0);53 return (54 <div>55 <PageHeader kicker="Transparency" title="Data" description="What RareIndex covers today, where it comes from, and how the numbers are computed. Coverage figures are live counts." />56 <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-8">57 {[58 ['Assets', fmtNum(stats.assets)],59 ['Sales', fmtNum(stats.sales)],60 ['Observations', fmtNum(stats.observations)],61 ['Active listings', fmtNum(stats.activeListings)],62 ['Sources', fmtNum(stats.sources)],63 ['Connectors', fmtNum(stats.connectors)],64 ['Categories with data', `${active.length}/${rows.length}`],65 ['FX rates', fx.rows ? `${fmtNum(fx.rows)} · ${fx.first}→${fx.last}` : '—'],66 ].map(([k, v]) => (67 <div key={k} className="bg-elevated px-3 py-2.5">68 <Stat label={k} value={v} />69 </div>70 ))}71 </section>7273 <Card className="mt-4 overflow-hidden">74 <CardHeader title="Connectors & sources" subtitle="Every source, what it contributes and its health. Trust scores weight sources in valuations." />75 {connectors.length ? (76 <Table>77 <thead>78 <tr>79 <th className={th}>Connector</th>80 <th className={th}>Source</th>81 <th className={th}>Type</th>82 <th className={th}>Contributes</th>83 <th className={th}>Categories</th>84 <th className={cn(th, 'text-right')}>Trust</th>85 <th className={cn(th, 'text-right')}>Raw</th>86 <th className={cn(th, 'text-right')}>Sales</th>87 <th className={cn(th, 'text-right')}>Listings</th>88 <th className={cn(th, 'text-right')}>Observations</th>89 <th className={th}>Health</th>90 <th className={th}>Last success</th>91 </tr>92 </thead>93 <tbody>94 {connectors.map((c) => (95 <tr key={c.id} className="hover:bg-sunken">96 <td className={cn(td, 'font-medium')}>{c.displayName}</td>97 <td className={td}>98 {c.sourceUrl ? (99 <a href={c.sourceUrl} target="_blank" rel="noopener nofollow" className="text-muted hover:text-fg">100 {c.sourceName} ↗101 </a>102 ) : (103 c.sourceName104 )}105 </td>106 <td className={cn(td, 'text-muted')}>{humanize(c.sourceType)}</td>107 <td className={cn(td, 'text-[11px] text-muted')}>{[c.supportsSold && 'sales', c.supportsListings && 'listings', c.supportsCatalog && 'catalog', c.supportsAuctions && 'auctions', c.supportsPopulation && 'population'].filter(Boolean).join(' · ') || '—'}</td>108 <td className={cn(td, 'max-w-[220px] truncate text-[11px] text-muted')} title={c.categories.map(catName).join(', ')}>109 {c.categories.map(catName).join(', ')}110 </td>111 <td className={tdNum}>{Math.round(c.trustScore * 100)}%</td>112 <td className={tdNum}>{fmtNum(c.rawRecords)}</td>113 <td className={tdNum}>{fmtNum(c.sales)}</td>114 <td className={tdNum}>{fmtNum(c.listings)}</td>115 <td className={tdNum}>{fmtNum(c.observations)}</td>116 <td className={td}>117 <Badge tone={c.healthStatus === 'healthy' ? 'gain' : c.healthStatus === 'degraded' ? 'alert' : c.healthStatus === 'failing' ? 'loss' : 'neutral'}>{c.healthStatus ?? c.status}</Badge>118 </td>119 <td className={cn(td, 'text-muted')}>{c.lastSuccessAt ? fmtRelative(c.lastSuccessAt) : 'never'}</td>120 </tr>121 ))}122 </tbody>123 </Table>124 ) : (125 <EmptyState title="No connectors registered yet" description="Connectors are declared in the registry and mirrored here after seeding." />126 )}127 </Card>128129 <Card className="mt-4 overflow-hidden">130 <CardHeader title="Coverage by category" subtitle="Live counts from canonical tables" />131 <Table>132 <thead>133 <tr>134 <th className={th}>Category</th>135 <th className={cn(th, 'text-right')}>Assets</th>136 <th className={cn(th, 'text-right')}>Priced</th>137 <th className={cn(th, 'text-right')}>Sales</th>138 <th className={cn(th, 'text-right')}>Listings</th>139 <th className={cn(th, 'text-right')}>Observations</th>140 <th className={th}>Last sale</th>141 </tr>142 </thead>143 <tbody>144 {[...rows].sort((a, b) => b.counts.assets - a.counts.assets).map((r) => (145 <tr key={r.node.slug} className={cn('hover:bg-sunken', r.counts.assets === 0 && 'text-subtle')}>146 <td className={td}>147 <Link href={`/markets/${r.node.slug}`} className="hover:underline">148 {r.node.name}149 </Link>150 <Badge className="ml-2">P{r.node.phase}</Badge>151 </td>152 <td className={tdNum}>{fmtNum(r.counts.assets)}</td>153 <td className={tdNum}>{fmtNum(r.counts.priced)}</td>154 <td className={tdNum}>{fmtNum(r.counts.sales)}</td>155 <td className={tdNum}>{fmtNum(r.counts.listings)}</td>156 <td className={tdNum}>{fmtNum(r.counts.observations)}</td>157 <td className={cn(td, 'text-muted')}>{r.counts.lastSaleAt ? fmtRelative(r.counts.lastSaleAt) : '—'}</td>158 </tr>159 ))}160 </tbody>161 </Table>162 </Card>163164 <div className="mt-4 grid gap-4 lg:grid-cols-[2fr_1fr]">165 <Card className="p-5" as="article">166 <div id="methodology" />167 {md ? <Markdown text={md} /> : <EmptyState title="Methodology document not found" />}168 </Card>169 <Card className="p-5 self-start">170 <h2 className="text-sm font-semibold">Exports & API</h2>171 <p className="mt-1 text-[12px] leading-relaxed text-muted">Professional and research users can export comparable sales, price histories and index series as CSV or JSON through the public API. Each export carries source attribution and the timestamp of the underlying observations.</p>172 <ul className="mt-3 space-y-1 text-[12px]">173 <li>174 <code className="rounded-sm bg-inset px-1">GET /v1/assets/:id/sales?format=csv</code>175 </li>176 <li>177 <code className="rounded-sm bg-inset px-1">GET /v1/assets/:id/history?format=csv</code>178 </li>179 <li>180 <code className="rounded-sm bg-inset px-1">GET /v1/indices/:ticker/history?format=json</code>181 </li>182 </ul>183 <Link href="/api-docs" className="mt-3 inline-block text-xs font-medium text-fg underline">184 API documentation →185 </Link>186 <h3 className="mt-5 text-sm font-semibold">Corrections</h3>187 <p className="mt-1 text-[12px] leading-relaxed text-muted">Spotted a misidentified item, a duplicate or a wrong price? Every record links to its source; write to data@rareindex.io with the asset URL and we will review it against the audit log.</p>188 </Card>189 </div>190 </div>191 );192}193