TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { requireUser } from '@/lib/auth/session';4import { indexSeries, listCollections, loadItems, portfolioHistory } from '@/lib/account/queries';5import { rebase, summarizePortfolio } from '@/lib/account/portfolio';6import { getDisplay } from '@/lib/account/display';7import { INDICES, indexForCategory } from '@rareindex/taxonomy';8import { PageHeader, btnSecondary } from '@/components/account/page-header';9import { LineChart } from '@/components/account/charts';10import { Card, CardHeader, Delta, Stat } from '@/components/ui/primitives';11import { pctChange, volatility, maxDrawdown } from '@rareindex/shared';12import { fmtPct } from '@/lib/format';1314export const metadata: Metadata = { title: 'My Index', robots: { index: false } };1516/** Personal index: the member's portfolio value rebased to 1000, against RARE and the relevant subindices. */17export default async function MyIndexPage() {18 const u = await requireUser('/my-index');19 const d = await getDisplay();20 const cols = await listCollections(u.id);21 const items = await loadItems(cols.map((c) => c.collection.id));22 const summary = summarizePortfolio(items);23 const history = await portfolioHistory(u.id);24 const since = history[0]?.date;25 const mine = rebase(history.map((h) => ({ date: h.date, value: h.valueUsd })));26 const tickers = ['RARE', ...new Set(summary.allocationByCategory.map((b) => indexForCategory(b.key)?.ticker).filter((t): t is string => Boolean(t)))].slice(0, 5);27 const bench = await Promise.all(tickers.map(async (t) => ({ ticker: t, points: rebase(await indexSeries(t, since)) })));28 const values = history.map((h) => h.valueUsd);29 const last = values[values.length - 1] ?? null;30 const nowMs = new Date().getTime();31 const at = (days: number) => {32 if (!history.length) return null;33 const target = new Date(nowMs - days * 86_400_000).toISOString().slice(0, 10);34 const p = [...history].reverse().find((h) => h.date <= target);35 return p ? pctChange(p.valueUsd, last) : null;36 };37 const vol = volatility(values, 365);38 const dd = maxDrawdown(values);3940 return (41 <>42 <PageHeader title="My Index" description="Your portfolio as an index: rebased to 1,000 on your first snapshot and compared with RARE and the subindices of the categories you hold." actions={<Link href="/collections" className={btnSecondary}>Collections</Link>} />43 <div className="mb-5 grid grid-cols-2 gap-4 sm:grid-cols-5">44 <Stat label="My index" value={mine.length ? mine[mine.length - 1]!.value.toFixed(1) : '—'} sub={history.length ? `${history.length} snapshots since ${since}` : 'no snapshots yet'} />45 <Stat label="7d" value={<Delta value={at(7)} className="text-lg" />} />46 <Stat label="30d" value={<Delta value={at(30)} className="text-lg" />} />47 <Stat label="Volatility (ann.)" value={vol === null ? '—' : fmtPct(vol, 1, false)} sub={values.length < 30 ? 'needs 30+ days' : 'from daily snapshots'} />48 <Stat label="Max drawdown" value={dd === null ? '—' : fmtPct(-dd, 1)} />49 </div>50 <Card>51 <CardHeader title="Rebased performance" subtitle={`Portfolio value in ${d.currency} vs indices · both rebased to 1,000`} />52 <div className="p-4">53 <LineChart series={[{ name: 'My index', points: mine }, ...bench.filter((b) => b.points.length > 1).map((b, i) => ({ name: b.ticker, points: b.points, color: INDICES.find((x) => x.ticker === b.ticker)?.color ?? `var(--ri-fg-subtle)`, dashed: i > 0 }))]} height={260} formatY={(v) => v.toFixed(0)} />54 <div className="mt-3 flex flex-wrap gap-4 text-xs text-muted">55 <span>56 <span className="mr-1 inline-block h-2 w-3 rounded-sm bg-index align-middle" /> My index57 </span>58 {bench.map((b) => (59 <span key={b.ticker}>60 <span className="mr-1 inline-block h-2 w-3 rounded-sm align-middle" style={{ background: INDICES.find((x) => x.ticker === b.ticker)?.color ?? 'var(--ri-fg-subtle)' }} /> {b.ticker}61 {b.points.length <= 1 ? <span className="text-subtle"> (no history yet)</span> : null}62 </span>63 ))}64 </div>65 <p className="mt-3 text-[11px] text-subtle">Your index reflects valuation changes and additions/removals; it is not a time-weighted return. Index values are published only when enough constituents are priced.</p>66 </div>67 </Card>68 </>69 );70}71