'use client'; import { useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; import { GradePill } from '@/components/GradeCard'; import { groupIntoLadders } from '@/lib/playerGrouping'; import { gradeColor } from '@/lib/vyndrTokens'; import { useAuth } from '@/contexts/AuthContext'; import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr'; import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; /** * The Ledger (Session 58, Phase 1) — the truth surface, now backed by the * persistent `ledger_entries` table. * * MY READS — the user's own scans, written the moment a scan completes. * MODEL — the PUBLIC pipeline record (every pre-grade, misses included). * * DATA SEMANTICS: lines/odds/books shown here are real captured book values * (mono, rendered as fact). model_value is VYNDR output and always carries * the MODEL label. The aggregate header NEVER renders a percentage under 20 * settles — it shows the building record honestly instead. */ type Tab = 'mine' | 'model'; type SportFilter = 'ALL' | 'NBA' | 'MLB' | 'WNBA'; type TierFilter = 'ALL' | 'A' | 'B' | 'C' | 'D'; interface LedgerRow { id: string; player_name: string; sport: string; stat: string; line: number; side: 'over' | 'under'; locked_odds?: string | null; book?: string | null; grade: string; edge?: number | null; confidence?: number | null; model_value?: number | null; graded_at: string; 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; // DS5 (Part 5) — the player's locked archetype, when the pipeline supplies it. // Optional + self-hiding: absent beats fabricated. archetype?: string | null; } interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null } // S6 (A1 board) — settled-CLV distribution bucket. Server-side only when the // n≥20 gate passes (null below — the gate lives in getModelAggregate). interface ClvBucket { label: string; count: number; side: 'beat' | 'faded' | 'flat' } interface ModelAggregate { 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; min_sample?: number; // Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier). by_tier?: Record; clv_distribution?: ClvBucket[] | null; } const SPORT_COLOR: Record = { nba: '#E94B3C', mlb: '#1E90FF', wnba: '#FFB347', soccer: '#7BC96F', }; export default function LedgerPage() { const { session } = useAuth(); const [tab, setTab] = useState('mine'); const [sport, setSport] = useState('ALL'); const [tier, setTier] = useState('ALL'); const [rows, setRows] = useState(null); const [aggregate, setAggregate] = useState(null); const [minSample, setMinSample] = useState(20); const load = useCallback(async () => { setRows(null); const params = new URLSearchParams(); if (sport !== 'ALL') params.set('sport', sport.toLowerCase()); if (tier !== 'ALL') params.set('tier', tier); try { if (tab === 'mine') { const token = session?.access_token; if (!token) { setRows([]); return; } const data = await fetch(`/api/ledger/mine?${params}`, { headers: { Authorization: `Bearer ${token}` }, }).then((r) => r.json()); setRows(Array.isArray(data?.entries) ? data.entries : []); } else { const data = await fetch(`/api/ledger/model?${params}`).then((r) => r.json()); setRows(Array.isArray(data?.entries) ? data.entries : []); setAggregate(data?.aggregate ?? null); if (Number(data?.min_sample) > 0) setMinSample(Number(data.min_sample)); } } catch { setRows([]); } }, [tab, sport, tier, session]); useEffect(() => { void load(); }, [load]); return (

The Ledger.

Every grade. Every result. No hiding. No deleting.

{/* Tabs */}
{([['mine', 'MY READS'], ['model', 'MODEL']] as [Tab, string][]).map(([id, label]) => { const active = tab === id; return ( ); })}
{/* MODEL aggregate header — never a percentage under min_sample. */} {tab === 'model' && aggregate && ( )} {/* Filters */}
setSport(v as SportFilter)} options={['ALL', 'NBA', 'MLB', 'WNBA']} /> setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C', 'D']} />
{rows === null ? ( // DS1 (§4) — grid of card skeletons matching the ledger's real layout.
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : rows.length === 0 ? ( ) : (
{groupIntoLadders(rows).map((g, i) => ( ))}
)}
); } /** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives * in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat * distribution renders as one giant bar + six empty stubs — it reads as * BROKEN. When CLV is flat we SAY so in one confident VOICE line (the record * is the best data on the site; it must look the most premium, never errored) * and show the bars only when there is real spread. */ function ClvDistribution({ agg }: { agg: ModelAggregate }) { const dist = agg.clv_distribution; const { mode } = clvMode(dist as Parameters[0]); if (mode === 'none') return null; if (mode === 'flat') { // Flat CLV is a FEATURE, not an error: the model grades outcomes, not the // close. State it plainly — premium, confident, no broken histogram. return (
CLOSING-LINE VALUE

{CLV_FLAT_LINE}

); } const distArr = dist as ClvBucket[]; const total = distArr.reduce((n, b) => n + b.count, 0); if (total === 0) return null; const max = Math.max(...distArr.map((b) => b.count)); const color = (side: ClvBucket['side']) => side === 'beat' ? 'var(--g-a, #00D4A0)' : side === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)'; return (
CLV DISTRIBUTION · {total} SETTLED
{distArr.map((b) => (
0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}> {b.count}
0 ? color(b.side) : 'var(--border)', borderRadius: 2, opacity: b.count > 0 ? 0.9 : 0.6, }} /> {b.label}
))}
); } function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) { const ready = agg.settled >= minSample && agg.hit_pct != null; return (
{ready ? (
MODEL · LAST 30D {agg.hits}-{agg.misses} · {agg.hit_pct}% HIT {agg.beat_close_pct != null && ( {agg.beat_close_pct}% BEAT CLOSE )} {agg.pending} pending
) : (

RECORD BUILDING

Every read settles here, misses included.{' '} {agg.pending} read{agg.pending === 1 ? '' : 's'} pending settlement {agg.settled > 0 && ( · {agg.settled} settled )}

)} {/* Wave 3 (Addition 2) — the ONE shared record-by-grade-tier table, identical here, on the dashboard, and on /u. */} {/* Wave 5A (D2) — the shareable house/model profile. Same record, a public page you can hand a partner. */}
VIEW AS PUBLIC PAGE →
); } function OutcomeChip({ row }: { row: LedgerRow }) { if (!row.outcome) { return PENDING; } 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 ( {mark}{row.actual_value != null ? ` (${row.actual_value})` : ''} ); } function ClvChip({ row }: { row: LedgerRow }) { 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 ( CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()} ); } function LedgerCard({ row, index, ladder }: { row: LedgerRow; index: number; ladder?: LedgerRow[] }) { const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)'; const rungs = Array.isArray(ladder) ? ladder : [row]; const isLadder = rungs.length > 1; return (
{row.sport.toUpperCase()} {/* Phase 2.5 — a revised grade is public, never silent. */} {row.revised_from_grade && ( {row.revised_from_grade} )}

{row.player_name}

{/* Part 5 — the archetype glyph+chip, propagated (self-hides when absent). showDesc renders INLINE (same row), so it costs no vertical height — kept for its one-line meaning. P2-10 density comes from the tighter padding/margins, not from stripping this. */} {row.archetype && (
)} {/* P0-3 — ONE card per player+market. Multiple lines nest as a ladder of rungs (each its own side/line + grade) instead of N separate cards. */} {isLadder ? ( <>

{row.stat.replace(/_/g, ' ')} · {rungs.length} lines

{rungs.map((rung) => ( {rung.side}{rung.line} {rung.grade} ))}
) : (

{row.side} {row.line} {row.stat.replace(/_/g, ' ')}

)}

{row.book ? : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {/* model_value is MODEL output — always labeled, never blended with market numbers. */} {row.model_value != null && · MODEL {row.model_value}}

); } function EmptyLedger({ tab }: { tab: Tab }) { // DS5 (#20) — the ONE unified empty surface, inline variant. return tab === 'mine' ? ( ) : ( ); } function FilterRow({ label, value, onChange, options }: { label: string; value: string; onChange: (v: string) => void; options: string[] }) { return (
{label.toUpperCase()} {options.map((o) => { const active = o === value; return ( ); })}
); }