Session 58: Phase 1 — Truth Infrastructure (2327 tests)
ledger_entries is live (migration 019 applied to prod, RLS + NULLS NOT
DISTINCT dedupe verified against the real database). Every grade now
persists, settles against the real result, and carries closing-line value.
- ledgerService: pipeline pre-grade upserts (public model record, user_id
null, idempotent), closing capture on every snapshot (last write before
game start = the close), settlement with SIGNED CLV (over = locked -
closing; beat/faded/flat), 30d model aggregate with the hard n>=20 rule.
- Write paths: snapshotService -> ledger (priority path); Next /api/scan ->
ledger for authenticated users only (anon never touches the public
record). Refused reads write nothing and don't burn a scan.
- Honest refusal (work-order 1.5): no projection => insufficient_data,
grade null, "INSUFFICIENT DATA - no read" UI. The web gradeAdapter no
longer displays the line as the model projection (the audit's
model==line / +0% edge degenerate); the card renders absent states.
projectionFor is sport-aware (l5 -> l20 -> {stat}_per_90 -> xG).
- /ledger: MY READS | MODEL tabs; model header shows hit% + beat-close%
only at n>=20, else RECORD BUILDING + live pending count. ModelRecord
deferred-render strip on landing + player hero. CLV + outcome chips,
revised_from_grade strikethrough (Phase 2.5 ready).
- SYNC (Task 5): thresholds vs SNAPSHOT_EXPECTED_INTERVAL (normal <1.5x,
amber >=1.5x, STALE red >=3x) via /api/snapshot/summary.
- Phase 2.5 logged in specs/vyndr-roadmap.md (build after Phase 3).
- Data-semantics hardening: strict null-safe numeric parsing everywhere a
market value is handled (Number(null)===0 would have fabricated lines).
Backend 2309 -> 2327 tests (201 suites), web build exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Personal ledger proxy (Session 58, Phase 1) — forwards GET /api/ledger/mine
|
||||
* with the caller's Authorization header (Express requireAuth scopes rows to
|
||||
* the authenticated user).
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (!auth) return NextResponse.json({ entries: [] }, { status: 401 });
|
||||
try {
|
||||
const qs = req.nextUrl.searchParams.toString();
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/ledger/mine${qs ? `?${qs}` : ''}`, {
|
||||
headers: { Accept: 'application/json', Authorization: auth },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ entries: [] }));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ entries: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Public model-record proxy (Session 58, Phase 1) — forwards
|
||||
* GET /api/ledger/model (the pipeline's user_id-null rows + 30d aggregate).
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const qs = req.nextUrl.searchParams.toString();
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/ledger/model${qs ? `?${qs}` : ''}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ entries: [], aggregate: null }));
|
||||
return NextResponse.json(data, {
|
||||
status: upstream.ok ? 200 : upstream.status,
|
||||
headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' },
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ entries: [], aggregate: null }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,21 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
let scansRemaining: number | null = null;
|
||||
|
||||
if (user && sb) {
|
||||
// Session 58 (work-order 1.5) — a refused read (no projection) writes
|
||||
// NOTHING: no scan_history, no ledger row. No hollow rows anywhere.
|
||||
const refused = data?.insufficient_data === true || !data?.grade;
|
||||
|
||||
if (user && sb && !refused) {
|
||||
// Phase 1 — persist the read to the ledger (authenticated users only;
|
||||
// anonymous scans are never written: a null user_id row would pollute
|
||||
// the PUBLIC model record, which is pipeline-only). The line/book are
|
||||
// the REAL book values the slate pre-filled; locked odds are enriched
|
||||
// from the cached odds feed when the prop matches. Fire-and-forget —
|
||||
// the scan response never waits on the ledger.
|
||||
void writeLedgerEntry(sb, user.id, body, data);
|
||||
}
|
||||
|
||||
if (user && sb && !refused) {
|
||||
void sb.rpc('increment_parlay_leg_frequency', {
|
||||
p_player: body.player,
|
||||
p_stat: body.stat,
|
||||
@@ -162,3 +176,68 @@ export async function POST(req: NextRequest) {
|
||||
return jsonError(502, 'The engine hit a wall. Try that read again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session 58 (Phase 1) — persist a completed user scan to ledger_entries.
|
||||
*
|
||||
* DATA SEMANTICS: `line`/`book` are the real book values the user scanned
|
||||
* (the slate pre-fills them from the odds feed). `locked_odds` attaches ONLY
|
||||
* when the cache-only snapshot carries the SAME line for this prop — odds
|
||||
* from a different line would be a fabrication, so absent beats wrong.
|
||||
* Upsert on the dedupe constraint: a double-tap never duplicates.
|
||||
*/
|
||||
async function writeLedgerEntry(
|
||||
sb: NonNullable<ReturnType<typeof getServiceRoleSupabase>>,
|
||||
userId: string,
|
||||
body: ScanBody,
|
||||
data: { grade?: string; projection?: number; confidence?: number; edge_pct?: number },
|
||||
) {
|
||||
try {
|
||||
const { nameKey, normalizeName } = await import('@/lib/playerName');
|
||||
const sport = body.sport.toLowerCase();
|
||||
const playerKey = nameKey(body.player);
|
||||
const gameDate = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(new Date());
|
||||
|
||||
// Cache-only snapshot read (never triggers an odds fetch → no quota).
|
||||
let lockedOdds: string | null = null;
|
||||
try {
|
||||
const snap = await fetch(`${BACKEND_URL}/api/snapshot/${sport}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
}).then((r) => (r.ok ? r.json() : null));
|
||||
const match = (snap?.grades || []).find(
|
||||
(g: { player?: string; player_name?: string; stat_type?: string; stat?: string; gradedAt?: { line?: number; odds?: number | string | null } }) =>
|
||||
nameKey(g.player || g.player_name || '') === playerKey
|
||||
&& String(g.stat_type || g.stat || '').toLowerCase() === body.stat.toLowerCase()
|
||||
&& g.gradedAt && Number(g.gradedAt.line) === Number(body.line),
|
||||
);
|
||||
if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds);
|
||||
} catch { /* absent beats wrong */ }
|
||||
|
||||
await sb.from('ledger_entries').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
player_key: playerKey,
|
||||
player_name: normalizeName(body.player).display || body.player,
|
||||
sport,
|
||||
stat: body.stat.toLowerCase(),
|
||||
line: body.line,
|
||||
side: body.direction,
|
||||
locked_odds: lockedOdds,
|
||||
book: body.book ?? 'draftkings',
|
||||
grade: data.grade,
|
||||
edge: typeof data.edge_pct === 'number' ? data.edge_pct : null,
|
||||
confidence: typeof data.confidence === 'number' ? data.confidence : null,
|
||||
model_value: typeof data.projection === 'number' ? data.projection : null,
|
||||
graded_at: new Date().toISOString(),
|
||||
game_id: `manual:${sport}:${gameDate}:${playerKey}`,
|
||||
game_date: gameDate,
|
||||
},
|
||||
{ onConflict: 'user_id,player_key,stat,line,side,game_id', ignoreDuplicates: true },
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[scan] ledger write failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
+235
-166
@@ -1,73 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
type Sport = 'ALL' | 'NBA' | 'MLB' | 'WNBA';
|
||||
type TierFilter = 'ALL' | 'A' | 'B' | 'C';
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface LedgerEntry {
|
||||
type Tab = 'mine' | 'model';
|
||||
type SportFilter = 'ALL' | 'NBA' | 'MLB' | 'WNBA';
|
||||
type TierFilter = 'ALL' | 'A' | 'B' | 'C' | 'D';
|
||||
|
||||
interface LedgerRow {
|
||||
id: string;
|
||||
player: string;
|
||||
player_name: string;
|
||||
sport: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
sport: Exclude<Sport, 'ALL'>;
|
||||
side: 'over' | 'under';
|
||||
locked_odds?: string | null;
|
||||
book?: string | null;
|
||||
grade: string;
|
||||
projection?: number;
|
||||
actual?: number;
|
||||
hit: boolean | null;
|
||||
miss_reason?: 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 AccuracyBucket {
|
||||
tier: string;
|
||||
interface ModelAggregate {
|
||||
settled: number;
|
||||
hits: number;
|
||||
losses: number;
|
||||
pct: 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;
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<Exclude<Sport, 'ALL'>, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
const SPORT_COLOR: Record<string, string> = {
|
||||
nba: '#E94B3C',
|
||||
mlb: '#1E90FF',
|
||||
wnba: '#FFB347',
|
||||
soccer: '#7BC96F',
|
||||
};
|
||||
|
||||
export default function LedgerPage() {
|
||||
const [sport, setSport] = useState<Sport>('ALL');
|
||||
const { session } = useAuth();
|
||||
const [tab, setTab] = useState<Tab>('mine');
|
||||
const [sport, setSport] = useState<SportFilter>('ALL');
|
||||
const [tier, setTier] = useState<TierFilter>('ALL');
|
||||
const [entries, setEntries] = useState<LedgerEntry[] | null>(null);
|
||||
const [accuracy, setAccuracy] = useState<AccuracyBucket[] | null>(null);
|
||||
const [rows, setRows] = useState<LedgerRow[] | null>(null);
|
||||
const [aggregate, setAggregate] = useState<ModelAggregate | null>(null);
|
||||
const [minSample, setMinSample] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
const load = useCallback(async () => {
|
||||
setRows(null);
|
||||
const params = new URLSearchParams();
|
||||
if (sport !== 'ALL') params.set('sport', sport);
|
||||
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]);
|
||||
|
||||
Promise.all([
|
||||
fetch(`/api/ledger?${params}`).then((r) => r.json()).catch(() => ({ entries: [] })),
|
||||
fetch('/api/ledger/accuracy').then((r) => r.json()).catch(() => ({ buckets: [] })),
|
||||
]).then(([entriesData, accuracyData]) => {
|
||||
setEntries(Array.isArray(entriesData?.entries) ? entriesData.entries : []);
|
||||
setAccuracy(Array.isArray(accuracyData?.buckets) ? accuracyData.buckets : []);
|
||||
});
|
||||
}, [sport, tier]);
|
||||
|
||||
const overall = useMemo(() => {
|
||||
if (!accuracy?.length) return null;
|
||||
const totals = accuracy.reduce(
|
||||
(acc, b) => ({ h: acc.h + b.hits, l: acc.l + b.losses }),
|
||||
{ h: 0, l: 0 },
|
||||
);
|
||||
const total = totals.h + totals.l;
|
||||
if (!total) return null;
|
||||
return { hits: totals.h, losses: totals.l, pct: Math.round((totals.h / total) * 100) };
|
||||
}, [accuracy]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 32 }}>
|
||||
<header style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 6 }}>
|
||||
The Ledger.
|
||||
</h1>
|
||||
@@ -76,122 +110,49 @@ export default function LedgerPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Accuracy header strip */}
|
||||
{accuracy && accuracy.length > 0 && (
|
||||
<section
|
||||
className="surface diagonal-cut animate-fade-up"
|
||||
style={{
|
||||
padding: 24,
|
||||
marginBottom: 24,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{accuracy.map((b) => (
|
||||
<AccuracyTile key={b.tier} bucket={b} />
|
||||
))}
|
||||
{overall && (
|
||||
<AccuracyTile
|
||||
bucket={{ tier: 'Overall', hits: overall.hits, losses: overall.losses, pct: overall.pct }}
|
||||
highlight
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
{/* 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 Sport)} options={['ALL', 'NBA', 'MLB', 'WNBA']} />
|
||||
<FilterRow label="Grade" value={tier} onChange={(v) => setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C']} />
|
||||
<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>
|
||||
|
||||
{/* Grid */}
|
||||
{entries === null ? (
|
||||
{rows === null ? (
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 32, textAlign: 'center' }}>Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<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>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
No grades 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 shows up here, with the result.
|
||||
</p>
|
||||
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
|
||||
Read a Prop →
|
||||
</a>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyLedger tab={tab} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 12,
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
<article
|
||||
key={entry.id}
|
||||
className={`surface diagonal-cut animate-fade-up stagger-${(i % 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: `${SPORT_COLOR[entry.sport]}1F`,
|
||||
color: SPORT_COLOR[entry.sport],
|
||||
}}
|
||||
>
|
||||
{entry.sport}
|
||||
</span>
|
||||
<GradePill grade={entry.grade} />
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{entry.player}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 12 }}>
|
||||
{entry.direction} {entry.line} {entry.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{entry.actual != null ? `Actual ${entry.actual}` : 'Pending'}
|
||||
</span>
|
||||
{entry.hit !== null && (
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: entry.hit ? 'var(--grade-a)' : 'var(--grade-d)',
|
||||
}}
|
||||
>
|
||||
{entry.hit ? 'HIT' : 'MISS'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.hit === false && entry.miss_reason && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 10,
|
||||
fontSize: 12,
|
||||
color: 'var(--grade-d)',
|
||||
background: 'rgba(255,107,107,0.10)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,107,107,0.30)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontWeight: 700 }}>Why we missed:</strong> {entry.miss_reason}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
<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>
|
||||
)}
|
||||
@@ -199,25 +160,133 @@ export default function LedgerPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AccuracyTile({ bucket, highlight }: { bucket: AccuracyBucket; highlight?: boolean }) {
|
||||
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
|
||||
const ready = agg.settled >= minSample && agg.hit_pct != null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
background: highlight ? 'var(--bg-elevated)' : 'var(--bg-surface)',
|
||||
border: highlight ? '1px solid var(--grade-a)' : '1px solid var(--border)',
|
||||
}}
|
||||
className="surface diagonal-cut"
|
||||
style={{ padding: 20, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
|
||||
>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{bucket.tier.toUpperCase()}
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
<div className="mono" style={{ fontSize: 24, fontWeight: 800, color: highlight ? 'var(--grade-a)' : 'var(--text-primary)', marginTop: 4 }}>
|
||||
{bucket.pct}%
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
|
||||
{bucket.hits}-{bucket.losses}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Hero from '@/components/Hero';
|
||||
import { ClaimMeter } from '@/components/vyndr';
|
||||
import { ClaimMeter, ModelRecord } from '@/components/vyndr';
|
||||
// Session 55 — live top A-rated grades pulled from tonight's real snapshot,
|
||||
// with the self-learning loop's accuracy line. The product shown, not described.
|
||||
import TopSignals from '@/components/TopSignals';
|
||||
@@ -50,6 +50,12 @@ export default function Home() {
|
||||
<Hero />
|
||||
{/* Session 55 — tonight's real top signals + live accuracy (the system works). */}
|
||||
<TopSignals />
|
||||
{/* Session 58 (work-order 1.4) — the public model record (proof-strip
|
||||
footer). Deferred-render: "RECORD BUILDING" until 20 settles, then
|
||||
the real hit% + beat-close%. Self-hides with no data. */}
|
||||
<div style={{ padding: '4px 16px 12px' }}>
|
||||
<ModelRecord />
|
||||
</div>
|
||||
{/* Founder-seat scarcity meter (§12) */}
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
<ClaimMeter />
|
||||
|
||||
@@ -6,6 +6,7 @@ import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||
|
||||
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
||||
@@ -93,6 +94,12 @@ export default function PlayerProfilePage() {
|
||||
{p.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{p.team}</span>}
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-2)' }}>{sportLabel(p.sport)}</span>
|
||||
</div>
|
||||
{/* Session 58 (work-order 1.4/§11) — VYNDR-on-player: the model's
|
||||
settled record on THIS player. Deferred-render; self-hides
|
||||
until the ledger has rows for them. */}
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<ModelRecord player={p.player} sport={p.sport} align="left" />
|
||||
</div>
|
||||
{p.archetype?.blend?.length > 0 && (
|
||||
<div style={{ marginTop: 15 }}>
|
||||
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
|
||||
|
||||
@@ -37,6 +37,8 @@ interface Player {
|
||||
|
||||
interface ScanResponse {
|
||||
grade: string;
|
||||
// Session 58 (work-order 1.5) — the model refused: no projection, no read.
|
||||
insufficient_data?: boolean;
|
||||
projection?: number;
|
||||
confidence?: number;
|
||||
sample_size?: number;
|
||||
@@ -266,7 +268,8 @@ export default function ScanPage() {
|
||||
return;
|
||||
}
|
||||
setResult(data);
|
||||
bumpScanCount();
|
||||
// Session 58 — a refused read (insufficient data) doesn't burn a scan.
|
||||
if (!data.insufficient_data) bumpScanCount();
|
||||
trackScanCompleted({
|
||||
sport,
|
||||
player: selectedPlayer,
|
||||
@@ -689,9 +692,31 @@ export default function ScanPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session 58 (work-order 1.5) — the honest refusal. When the model has
|
||||
no projection there is NO read: no grade letter, no fake +0% edge,
|
||||
and nothing writes to the ledger. A refused read builds more trust
|
||||
than a hollow one. */}
|
||||
{result && result.insufficient_data && (
|
||||
<div
|
||||
className="surface scanlines"
|
||||
style={{ marginTop: 32, padding: 32, textAlign: 'center', border: '1px solid var(--border-hi)', borderRadius: 10, display: 'grid', gap: 10, justifyItems: 'center' }}
|
||||
>
|
||||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--amber)' }}>
|
||||
INSUFFICIENT DATA — NO READ
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>
|
||||
The model has no projection for this prop, so it refuses to grade it.
|
||||
No number gets invented here — that's the deal.
|
||||
</p>
|
||||
<button onClick={reset} className="btn-ghost" style={{ marginTop: 6, padding: '10px 18px' }}>
|
||||
Read another prop →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grade result — VYNDR 2.0 ProcessingGrade → GradeResultCard (Session 35).
|
||||
Engine output is mapped to the §7 contract and tier-gated by the adapter. */}
|
||||
{result && (
|
||||
{result && !result.insufficient_data && (
|
||||
<div style={{ marginTop: 32, display: 'grid', gap: 16 }}>
|
||||
<ProcessingGrade
|
||||
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
|
||||
|
||||
Reference in New Issue
Block a user