Files
vyndr/web/src/app/ledger/page.tsx
T
builtbykev 71e35e90fd P2-10: ledger read-card density — tighter padding/margins (3-4 per phone screen)
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>
2026-07-17 01:47:48 -04:00

441 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}