Wire BookComparison to the prop card (display-only, honest states)

BookComparison.tsx was built but UNROUTED (dead). Route it to the GradeResultCard
via a new self-fetching BookComparisonPanel that reads the live /api/books feed
(source:'bookprices' — the snapshot-locked, fenced, byte-identical store).

Contract fix (Review Zero 0.1): books frequently sit at DIFFERENT lines (WNBA DK
21.5 / FD 18.5; MLB 2/3), so BookComparison now renders EACH book's own line
per-row — never one shared header line implying a false same-number comparison.

Honest states: single-book (the common case for MLB) → one book, "One book
posting this prop.", NO crown/second row; multi-book → all books' own line+price,
NONE crowned (BOOK_CROWN_ENABLED=false — no best-price claim, verified live
crowned:false); no books → renders NULL (panel self-hides), never a placeholder.

No regression: only the always-empty inline d.books section was replaced; grade,
projection, PropLine line, and PriceTriplet price are untouched (wiring test
asserts them). Freshness (0.4): bookprices is written in the SAME snapshot that
locks the grade (intraday refresh touches neither gradedAt.line nor bookprices) —
same fresh, no stale-label needed. Web-only → grade byte-identical trivially.
Full suite 3851 green, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
This commit is contained in:
Kev
2026-07-29 04:09:34 -04:00
parent 3a05447f77
commit 2ab2eeaa7d
5 changed files with 179 additions and 30 deletions
+28 -7
View File
@@ -26,6 +26,8 @@ export interface BookComparisonProps {
side?: 'over' | 'under';
books: BookRow[];
savings?: number;
/** The panel already labels the section; drop the internal caption there. */
showHeader?: boolean;
}
function fmt(o?: number | null) {
@@ -33,21 +35,28 @@ function fmt(o?: number | null) {
return o > 0 ? `+${o}` : `${o}`;
}
export default function BookComparison({ player, stat, line, side = 'over', books, savings }: BookComparisonProps) {
export default function BookComparison({ player, stat, line, side = 'over', books, savings, showHeader = true }: BookComparisonProps) {
if (!books || books.length === 0) return null;
const sideChar = side === 'under' ? 'u' : 'o';
const single = books.length === 1;
return (
<div className="book-comparison" style={{ display: 'grid', gap: 8 }}>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
{player} · {stat.replace(/_/g, ' ')}{line != null ? ` ${line}` : ''} · {side}
</div>
{showHeader && (
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
{player} · {stat.replace(/_/g, ' ')} · {side}
</div>
)}
<div style={{ display: 'grid', gap: 4 }}>
{books.map((b) => (
<div
key={b.book}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto auto',
gap: 10,
// book · that book's OWN line · price(s) · best. Books frequently sit
// at DIFFERENT lines, so the line is per-row — never one shared header
// line implying a false same-number comparison.
gridTemplateColumns: '1fr auto auto auto',
gap: 12,
alignItems: 'center',
padding: '6px 10px',
borderRadius: 8,
@@ -58,7 +67,10 @@ export default function BookComparison({ player, stat, line, side = 'over', book
{/* M3.3a — real branded book wordmark, never the capitalized raw
key ("Betmgm"). The entity-layer BookChip resolves DK/FD/MGM/… */}
<BookChip book={b.book} showName />
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary, #8A8A9A)' }}>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1, #B8BCC8)', fontVariantNumeric: 'tabular-nums' }}>
{b.line != null ? `${sideChar}${b.line}` : '—'}
</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary, #8A8A9A)', fontVariantNumeric: 'tabular-nums' }}>
{fmt(b.over_odds)} / {fmt(b.under_odds)}
</span>
{b.isBest ? (
@@ -67,6 +79,15 @@ export default function BookComparison({ player, stat, line, side = 'over', book
</div>
))}
</div>
{/* Honest single-book state: intentional, not broken — no crown, no implied
second row, no "comparison" framing over a lone price. */}
{single && (
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary, #6B6B7B)', letterSpacing: '0.04em' }}>
One book posting this prop.
</div>
)}
{/* Savings only renders when a crown was earned (≥2 books, same line,
differing prices, flag on). Crown is off today → savings 0 → hidden. */}
{savings != null && savings > 0 && (
<div style={{ fontSize: 12, color: 'var(--grade-a, #00D4A0)' }}>
Betting the best line saves ~${savings.toFixed(2)} per $100.
@@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import BookComparison, { type BookRow } from '@/components/BookComparison';
import SectionHead from '@/components/vyndr/SectionHead';
/**
* BookComparisonPanel — the ONE place the (previously-dead) BookComparison grid
* is wired to a real feed. Self-fetches `/api/books/:sport/:player/:stat` (which
* reads the snapshot-locked, display-only `bookprices` store — fenced from the
* grade path) and renders BookComparison's honest states:
* - single-book (the common case): one book, no crown, no fake second row
* - multi-book: every book's OWN line + price, none crowned (crown is gated OFF)
* - no books / error: honest-absent — renders NOTHING (the card omits the section)
*
* DISPLAY-ONLY. It reads a public read-only endpoint; it touches no grade, line,
* projection, or price on the card. Freshness: `bookprices` is written in the SAME
* snapshot that locks the grade (same `props`, same `ts`), so these prices are as
* fresh as the graded line — no drift, no stale-label needed.
*/
interface Resp {
books?: BookRow[];
line?: number | null;
savings?: number;
}
export default function BookComparisonPanel({
sport,
player,
stat,
side,
}: {
sport: string;
player: string;
stat: string;
side: 'Over' | 'Under';
}) {
const [data, setData] = useState<Resp | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
let alive = true;
if (!sport || !player || !stat) { setDone(true); return; }
const s = side === 'Under' ? 'under' : 'over';
fetch(`/api/books/${encodeURIComponent(sport)}/${encodeURIComponent(player)}/${encodeURIComponent(stat)}?side=${s}`, { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((d: Resp | null) => { if (alive) { setData(d && Array.isArray(d.books) ? d : null); setDone(true); } })
.catch(() => { if (alive) setDone(true); });
return () => { alive = false; };
}, [sport, player, stat, side]);
// Honest-absent: until we have real book rows, render nothing at all — never a
// placeholder, never a fabricated book, never an empty labelled box.
if (!done || !data || !Array.isArray(data.books) || data.books.length === 0) return null;
return (
<div style={{ padding: '0 20px 16px' }}>
<SectionHead style={{ marginBottom: 10 }}>BOOK COMPARISON</SectionHead>
<BookComparison
player={player}
stat={stat}
line={data.line}
side={side === 'Under' ? 'under' : 'over'}
books={data.books}
savings={data.savings}
showHeader={false}
/>
</div>
);
}
+7 -19
View File
@@ -9,6 +9,7 @@ import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
import GradeBadge from '@/components/vyndr/GradeBadge';
import GradeShift from '@/components/vyndr/GradeShift';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import BookComparisonPanel from '@/components/vyndr/BookComparisonPanel';
import { type HeadshotSport } from '@/lib/playerHeadshot';
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
import { edgeColor, gradeGlows } from '@/lib/colorContract';
@@ -93,7 +94,6 @@ export default function GradeResultCard({
// colorContract.edgeColor helper (imported) — negative = var(--miss). DS4
// introduced a local const that DS3 superseded with the shared enforcer.
const hasKill = !!d.killConditions && d.killConditions.length > 0;
const hasBooks = Array.isArray(d.books) && d.books.length > 0;
const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0;
return (
@@ -303,24 +303,12 @@ export default function GradeResultCard({
</div>
)}
{/* 7. BOOK COMPARISON */}
{hasBooks && (
<div style={{ padding: '0 20px 16px' }}>
<SectionHead style={{ marginBottom: 10 }}>BOOK COMPARISON</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{d.books.map((b, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 13px', borderRadius: 6, background: b.best ? 'rgba(0,212,160,.13)' : 'var(--bg-2)', borderLeft: b.best ? '2px solid var(--g-a)' : '2px solid transparent' }}>
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: b.best ? 'var(--g-a)' : 'var(--text-0)' }}>{b.name}</span>
<div className="mono" style={{ fontSize: 13, display: 'flex', gap: 14, alignItems: 'center' }}>
<span style={{ color: 'var(--text-1)' }}>{d.side === 'Under' ? 'U' : 'O'}{b.line}</span>
<span style={{ color: b.best ? 'var(--g-a)' : 'var(--text-0)', fontWeight: 700, minWidth: 44, textAlign: 'right' }}>{b.odds}</span>
{b.best && <span className="label" style={{ color: 'var(--g-a)', fontSize: 9 }}>BEST</span>}
</div>
</div>
))}
</div>
</div>
)}
{/* 7. BOOK COMPARISON — the (previously-dead) BookComparison grid, now wired
to the live /api/books feed via BookComparisonPanel. Self-fetches +
self-hides (honest-absent). Display-only; touches no grade/line/price
above. Crown stays OFF (BOOK_CROWN_ENABLED=false) — no best-price claim. */}
<BookComparisonPanel sport={d.sport} player={d.player} stat={d.stat} side={d.side} />
{/* 8. ALT LINE LADDER (Desk) */}
{hasAlt && (