Files
vyndr/web/src/app/ledger/page.tsx
T
builtbykev f110bd63f1 Session F (night2): Phase 5 — records + dossier
5.1 Archetype defined on-page: one line from the archetype library under
    ARCHETYPE DNA (BOMBER — elite raw power…); expander keeps the long form.
5.2 VYNDR-on-team live: getModelAggregate team scope (migration-020 column)
    + /api/ledger/model?team= + ModelRecord mounted on the Team Hub header.
5.3 WNBA/NBA profile parity: minutes-based usage (+MIN season cell) when
    the feed carries minutes — absent beats invented.
5.4 Settings read meter: real rolling-24h usage from the SAME store the
    limiter enforces (GET /api/user/scan-meter + proxy). Metered tiers see
    'X of N reads today' + bar; unlimited tiers see nothing. Corrects the
    stale '5 scans / month' copy.
5.5 Per-tier calibration on the MODEL tab: A+/A/B/C chips with hit% at
    n>=20 PER TIER, 'building (n/20)' below — the separation between tiers
    is the proof the grades mean something.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:05:29 -04:00

346 lines
14 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 { GradePill } from '@/components/GradeCard';
import { useAuth } from '@/contexts/AuthContext';
/**
* 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;
}
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
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>;
}
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 ? (
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 32, textAlign: 'center' }}>Loading</p>
) : rows.length === 0 ? (
<EmptyLedger tab={tab} />
) : (
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
{rows.map((row, i) => (
<LedgerCard key={row.id} row={row} index={i} />
))}
</div>
)}
</section>
);
}
const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F'];
/** Session 60 (5.5) — per-tier calibration: the separation between tiers is
* the proof the grades mean something. A tier under n≥20 shows "building",
* never a small-sample percentage. Self-hides until ANY tier is ready. */
function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
const tiers = agg.by_tier || {};
const anyReady = TIER_ORDER.some((t) => tiers[t] && tiers[t].hit_pct != null);
if (!anyReady) return null;
return (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => {
const b = tiers[t];
const ready = b.hit_pct != null;
return (
<span key={t} className="mono" style={{ fontSize: 11.5, fontWeight: 700, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-hi)', color: ready ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
{t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`}
</span>
);
})}
</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>
)}
<TierCalibration agg={agg} minSample={minSample} />
</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 }: { row: LedgerRow; 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 }}>
{/* 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: 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}
{/* 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 }) {
return (
<div className="surface diagonal-cut tex-scan" style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
{tab === 'mine' ? (
<>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No reads yet.</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
Read your first prop to start building your Ledger. Every grade you run lands here the moment it completes and settles against the real result.
</p>
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
Read a Prop
</a>
</>
) : (
<>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>The model record starts with the next pipeline run.</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
Every pre-graded prop lands here hits, misses, pushes, and closing-line value. Nothing is deleted.
</p>
</>
)}
</div>
);
}
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>
);
}