S10 (a1): public ledger profiles v1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 19:32:22 -04:00
parent aaafc3e0f2
commit b20145c215
19 changed files with 1226 additions and 6 deletions
+247
View File
@@ -0,0 +1,247 @@
'use client';
import { useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard';
/**
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
*
* DATA SEMANTICS: every row here is a REAL settled ledger entry — outcome,
* actual value, closing-line value. Nothing curated: publishing shows the
* ENTIRE settled record, misses included. The header NEVER renders a
* percentage under min_sample (20) settles — it shows RECORD BUILDING.
*
* Unknown and unpublished handles arrive as the same 404 — the page renders
* one indistinguishable not-found state for both.
*/
interface ProfileRow {
id: string;
player_name: string;
sport: string;
stat: string;
line: number;
side: 'over' | 'under';
locked_odds?: string | null;
book?: string | null;
grade: string;
model_value?: number | null;
game_date: string;
closing_line?: number | null;
clv?: number | null;
clv_result?: 'beat' | 'faded' | 'flat' | null;
outcome?: 'hit' | 'miss' | 'push' | null;
actual_value?: number | null;
revised_from_grade?: string | null;
}
interface ProfileAggregate {
settled: number;
hits: number;
misses: number;
pushes: number;
hit_pct: number | null;
clv_sample: number;
clv_beat: number;
beat_close_pct: number | null;
pending: number;
}
interface ProfilePayload {
handle: string;
aggregate: ProfileAggregate | null;
entries: ProfileRow[];
min_sample?: number;
}
const SPORT_COLOR: Record<string, string> = {
nba: '#E94B3C',
mlb: '#1E90FF',
wnba: '#FFB347',
soccer: '#7BC96F',
};
export default function PublicProfile({ handle }: { handle: string }) {
const [data, setData] = useState<ProfilePayload | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'notfound'>('loading');
useEffect(() => {
let active = true;
setState('loading');
fetch(`/api/profiles/${encodeURIComponent(handle)}`)
.then(async (r) => {
if (!active) return;
if (!r.ok) { setState('notfound'); return; }
const d = await r.json();
if (!active) return;
setData(d);
setState('ready');
})
.catch(() => { if (active) setState('notfound'); });
return () => { active = false; };
}, [handle]);
if (state === 'loading') {
return (
<section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading record</p>
</section>
);
}
if (state === 'notfound' || !data) {
// Same state for unknown AND unpublished — the API never tells us which.
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '64px 16px 120px', textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--amber, #FFB347)', marginBottom: 10 }}>
NO RECORD HERE
</p>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>This profile does not exist or is not published.</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
VYNDR ledgers are private by default. A record only appears here when its owner publishes it.
</p>
</section>
);
}
const agg = data.aggregate;
const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20;
return (
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
<header style={{ marginBottom: 20 }}>
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--g-a, #00D4A0)', marginBottom: 8 }}>
PUBLIC LEDGER
</p>
<h1 className="mono" style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-0.02em', marginBottom: 6 }}>
@{data.handle}
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 15 }}>
Every settled read. Wins and misses. Nothing curated.
</p>
</header>
{/* Record header — never a percentage under min_sample settles. */}
<RecordHeader agg={agg} minSample={minSample} />
{data.entries.length === 0 ? (
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
NO SETTLED READS YET
</p>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Reads land here as they settle against real results.
</p>
</div>
) : (
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
{data.entries.map((row, i) => (
<ProfileCard key={row.id} row={row} index={i} />
))}
</div>
)}
</section>
);
}
function RecordHeader({ agg, minSample }: { agg: ProfileAggregate | null; minSample: number }) {
const ready = Boolean(agg && agg.settled >= minSample && agg.hit_pct != null);
return (
<div
className="surface diagonal-cut"
style={{ padding: 20, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
>
{ready && agg ? (
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'baseline' }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
RECORD · LAST 30D
</span>
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--g-a, #00D4A0)' }}>
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT
</span>
{agg.beat_close_pct != null && (
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>
{agg.beat_close_pct}% BEAT CLOSE
</span>
)}
<span className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{agg.pending} pending
</span>
</div>
) : (
<div>
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
RECORD BUILDING
</p>
<p style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
Percentages render at {minSample} settled reads.
{agg && (
<span className="mono" style={{ color: 'var(--text-primary)' }}>
{' '}{agg.settled} settled · {agg.pending} pending
</span>
)}
</p>
</div>
)}
</div>
);
}
function OutcomeChip({ row }: { row: ProfileRow }) {
if (!row.outcome) {
return <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>PENDING</span>;
}
const color = row.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
: row.outcome === 'miss' ? 'var(--miss, #FF6B6B)' : 'var(--text-secondary)';
const mark = row.outcome === 'hit' ? '✓ HIT' : row.outcome === 'miss' ? '✕ MISS' : ' PUSH';
return (
<span className="mono" style={{ fontSize: 12, fontWeight: 700, color }}>
{mark}{row.actual_value != null ? ` (${row.actual_value})` : ''}
</span>
);
}
function ClvChip({ row }: { row: ProfileRow }) {
if (!row.clv_result || row.clv == null) return null;
const color = row.clv_result === 'beat' ? 'var(--g-a, #00D4A0)'
: row.clv_result === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
return (
<span className="mono" title={`Closing line value: locked ${row.line}, closed ${row.closing_line}`}
style={{ fontSize: 10.5, fontWeight: 700, color, letterSpacing: '0.04em' }}>
CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()}
</span>
);
}
function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
return (
<article className={`surface diagonal-cut animate-fade-up stagger-${(index % 6) + 1}`} style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: `${sportColor}1F`, color: sportColor }}>
{row.sport.toUpperCase()}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{row.revised_from_grade && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
{row.revised_from_grade}
</span>
)}
<GradePill grade={row.grade} />
</span>
</div>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
<OutcomeChip row={row} />
<ClvChip row={row} />
</div>
</article>
);
}