71e35e90fd
Phone audit: read cards were huge, only 1-2 fit per screen. Compressed the
vertical spacing — article padding 16->12, header margin 8->5, name 15->14px,
ladder-rungs margin 10->8, book/date line 12->8.
Kept the archetype showDesc: it renders INLINE (same row as the badge), so it
adds zero vertical height — dropping it wouldn't help density and would break
the ds5 design lock ("the badge shows its one-line meaning where it leads").
Locked the density in vyndrParityQA (P2-10).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
441 lines
19 KiB
TypeScript
441 lines
19 KiB
TypeScript
'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<string, TierRecord>;
|
||
clv_distribution?: ClvBucket[] | null;
|
||
}
|
||
|
||
const SPORT_COLOR: Record<string, string> = {
|
||
nba: '#E94B3C',
|
||
mlb: '#1E90FF',
|
||
wnba: '#FFB347',
|
||
soccer: '#7BC96F',
|
||
};
|
||
|
||
export default function LedgerPage() {
|
||
const { session } = useAuth();
|
||
const [tab, setTab] = useState<Tab>('mine');
|
||
const [sport, setSport] = useState<SportFilter>('ALL');
|
||
const [tier, setTier] = useState<TierFilter>('ALL');
|
||
const [rows, setRows] = useState<LedgerRow[] | null>(null);
|
||
const [aggregate, setAggregate] = useState<ModelAggregate | null>(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 (
|
||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||
<header style={{ marginBottom: 24 }}>
|
||
<h1 style={{ fontSize: 32, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 6 }}>
|
||
The Ledger.
|
||
</h1>
|
||
<p style={{ color: 'var(--text-secondary)', fontSize: 16 }}>
|
||
Every grade. Every result. No hiding. No deleting.
|
||
</p>
|
||
</header>
|
||
|
||
{/* Tabs */}
|
||
<div role="tablist" aria-label="Ledger view" style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border)' }}>
|
||
{([['mine', 'MY READS'], ['model', 'MODEL']] as [Tab, string][]).map(([id, label]) => {
|
||
const active = tab === id;
|
||
return (
|
||
<button
|
||
key={id}
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => setTab(id)}
|
||
className="mono"
|
||
style={{
|
||
padding: '12px 20px', background: 'transparent', border: 'none',
|
||
borderBottom: `2px solid ${active ? 'var(--g-a, #00D4A0)' : 'transparent'}`,
|
||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||
fontWeight: 700, fontSize: 12, letterSpacing: '0.08em', cursor: 'pointer', marginBottom: -1,
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* MODEL aggregate header — never a percentage under min_sample. */}
|
||
{tab === 'model' && aggregate && (
|
||
<ModelHeader agg={aggregate} minSample={minSample} />
|
||
)}
|
||
|
||
{/* Filters */}
|
||
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||
<FilterRow label="Sport" value={sport} onChange={(v) => setSport(v as SportFilter)} options={['ALL', 'NBA', 'MLB', 'WNBA']} />
|
||
<FilterRow label="Grade" value={tier} onChange={(v) => setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C', 'D']} />
|
||
</div>
|
||
|
||
{rows === null ? (
|
||
// DS1 (§4) — grid of card skeletons matching the ledger's real layout.
|
||
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }} aria-busy="true">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<Skeleton key={i} height={132} radius={14} />
|
||
))}
|
||
</div>
|
||
) : rows.length === 0 ? (
|
||
<EmptyLedger tab={tab} />
|
||
) : (
|
||
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
|
||
{groupIntoLadders(rows).map((g, i) => (
|
||
<LedgerCard key={(g.primary as LedgerRow).id} row={g.primary as LedgerRow} ladder={g.ladder as LedgerRow[]} index={i} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** 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<typeof clvMode>[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 (
|
||
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 6 }}>
|
||
CLOSING-LINE VALUE
|
||
</div>
|
||
<p className="mono" style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', margin: 0, letterSpacing: '0.01em' }}>
|
||
{CLV_FLAT_LINE}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||
CLV DISTRIBUTION · {total} SETTLED
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', maxWidth: 420 }}>
|
||
{distArr.map((b) => (
|
||
<div key={b.label} title={`${b.label}: ${b.count}`} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 0 }}>
|
||
<span className="mono" style={{ fontSize: 10, fontVariantNumeric: 'tabular-nums', color: b.count > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}>
|
||
{b.count}
|
||
</span>
|
||
<div
|
||
aria-label={`${b.label}: ${b.count} settled`}
|
||
style={{
|
||
width: '100%',
|
||
height: Math.max(3, Math.round((b.count / max) * 40)),
|
||
background: b.count > 0 ? color(b.side) : 'var(--border)',
|
||
borderRadius: 2,
|
||
opacity: b.count > 0 ? 0.9 : 0.6,
|
||
}}
|
||
/>
|
||
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '0.02em', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||
{b.label}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
|
||
const ready = 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 ? (
|
||
<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)' }}>
|
||
MODEL · 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)' }}>
|
||
Every read settles here, misses included.{' '}
|
||
<span className="mono" style={{ color: 'var(--text-primary)' }}>
|
||
{agg.pending} read{agg.pending === 1 ? '' : 's'} pending settlement
|
||
</span>
|
||
{agg.settled > 0 && (
|
||
<span className="mono" style={{ color: 'var(--text-tertiary)' }}> · {agg.settled} settled</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<ClvDistribution agg={agg} />
|
||
{/* Wave 3 (Addition 2) — the ONE shared record-by-grade-tier table,
|
||
identical here, on the dashboard, and on /u. */}
|
||
<TierRecord byTier={agg.by_tier} minSample={minSample} />
|
||
{/* Wave 5A (D2) — the shareable house/model profile. Same record, a
|
||
public page you can hand a partner. */}
|
||
<div style={{ marginTop: 14, textAlign: 'right' }}>
|
||
<Link
|
||
href="/u/vyndr"
|
||
className="mono"
|
||
style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--g-a, #00D4A0)', textDecoration: 'none' }}
|
||
>
|
||
VIEW AS PUBLIC PAGE →
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OutcomeChip({ row }: { row: LedgerRow }) {
|
||
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: 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 (
|
||
<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 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 (
|
||
<article className={`surface diagonal-cut animate-fade-up stagger-${(index % 6) + 1}`} style={{ padding: 12 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 5 }}>
|
||
<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 }}>
|
||
{/* Phase 2.5 — a revised grade is public, never silent. */}
|
||
{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: 14, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
|
||
{/* 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 && (
|
||
<div style={{ marginBottom: 4 }}>
|
||
<ArchetypeBadge archetype={row.archetype} size="sm" showDesc />
|
||
</div>
|
||
)}
|
||
{/* 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 ? (
|
||
<>
|
||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 6 }}>
|
||
{row.stat.replace(/_/g, ' ')} <span style={{ color: 'var(--text-tertiary)' }}>· {rungs.length} lines</span>
|
||
</p>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||
{rungs.map((rung) => (
|
||
<span key={rung.id} className="mono" style={{ fontSize: 10.5, display: 'inline-flex', alignItems: 'center', gap: 5, padding: '3px 8px', borderRadius: 6, background: 'var(--bg-2)', border: '1px solid var(--border)' }}>
|
||
{rung.side}{rung.line}
|
||
<span style={{ fontWeight: 800, color: gradeColor(rung.grade) }}>{rung.grade}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<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: 8 }}>
|
||
{row.book ? <BookWordmark book={row.book} size={11} /> : '—'}{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 && <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>
|
||
);
|
||
}
|
||
|
||
function EmptyLedger({ tab }: { tab: Tab }) {
|
||
// DS5 (#20) — the ONE unified empty surface, inline variant.
|
||
return tab === 'mine' ? (
|
||
<EmptyState
|
||
inline
|
||
wordmark={false}
|
||
code="LEDGER EMPTY"
|
||
title="No reads yet."
|
||
message="Read your first prop to start building your Ledger. Every grade lands here the moment it completes — and settles against the real result."
|
||
actions={[{ label: 'Read a Prop →', href: '/scan', primary: true }]}
|
||
/>
|
||
) : (
|
||
<EmptyState
|
||
inline
|
||
wordmark={false}
|
||
code="MODEL RECORD BUILDING"
|
||
title="The record starts with the next pipeline run."
|
||
message="Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted."
|
||
/>
|
||
);
|
||
}
|
||
|
||
function FilterRow({ label, value, onChange, options }: { label: string; value: string; onChange: (v: string) => void; options: string[] }) {
|
||
return (
|
||
<div>
|
||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em', marginRight: 12 }}>
|
||
{label.toUpperCase()}
|
||
</span>
|
||
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||
{options.map((o) => {
|
||
const active = o === value;
|
||
return (
|
||
<button
|
||
key={o}
|
||
onClick={() => onChange(o)}
|
||
className={active ? 'btn-primary' : 'btn-ghost'}
|
||
style={{ padding: '6px 12px', fontSize: 11 }}
|
||
>
|
||
{o}
|
||
</button>
|
||
);
|
||
})}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|