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
+65
View File
@@ -0,0 +1,65 @@
// Book Comparison wired to the prop card (display-only). Source-assertion style,
// matching vyndrCoreScreens.test.js.
const fs = require('fs');
const path = require('path');
const read = (p) => fs.readFileSync(path.join(__dirname, '../../web/src', p), 'utf8');
describe('BookComparison — honest states', () => {
const src = read('components/BookComparison.tsx');
it('renders EACH books own line (books frequently differ in line)', () => {
// per-row line, not one shared header line.
expect(src).toMatch(/sideChar\}\$\{b\.line\}/);
expect(src).toMatch(/gridTemplateColumns: '1fr auto auto auto'/); // book · line · price · best
});
it('single-book renders an intentional state, never a fake second row', () => {
expect(src).toMatch(/const single = books\.length === 1/);
expect(src).toContain('One book posting this prop.');
});
it('crown/BEST only shows on isBest (gated off → never shows)', () => {
expect(src).toContain('b.isBest ?');
// savings copy is guarded behind savings > 0 (0 when crown off)
expect(src).toMatch(/savings != null && savings > 0/);
});
it('empty book list → renders nothing (honest-absent)', () => {
expect(src).toMatch(/if \(!books \|\| books\.length === 0\) return null/);
});
});
describe('BookComparisonPanel — the wire to /api/books', () => {
const src = read('components/vyndr/BookComparisonPanel.tsx');
it('fetches the live /api/books per-prop endpoint', () => {
expect(src).toMatch(/\/api\/books\/\$\{encodeURIComponent\(sport\)\}/);
expect(src).toContain('BookComparison');
});
it('honest-absent: renders NULL until real book rows exist', () => {
expect(src).toMatch(/data\.books\.length === 0\) return null/);
});
it('does not enable the crown or pass a fabricated best', () => {
expect(src).not.toMatch(/isBest:\s*true/);
expect(src).not.toContain('BOOK_CROWN_ENABLED');
});
});
describe('GradeResultCard — wired + existing elements survive', () => {
const src = read('components/vyndr/GradeResultCard.tsx');
it('renders the live BookComparisonPanel (BookComparison is no longer dead)', () => {
expect(src).toContain('import BookComparisonPanel');
expect(src).toContain('<BookComparisonPanel sport={d.sport} player={d.player} stat={d.stat} side={d.side} />');
});
it('the existing grade / projection / line / price elements are untouched', () => {
expect(src).toContain('PriceTriplet'); // the price layer
expect(src).toMatch(/PROJECTION|d\.projection/); // projection row
expect(src).toContain('GradeBadge'); // the grade
expect(src).toContain("l: 'LINE'"); // the PropLine line cell
});
});
+9 -4
View File
@@ -78,9 +78,12 @@ describe('Phase D — GradeResultCard (the core moment)', () => {
expect(src).toContain('intel-surface'); expect(src).toContain('intel-surface');
expect(src).toContain('grade-reveal'); expect(src).toContain('grade-reveal');
}); });
it('highlights the best book with green tint + green left border', () => { it('book comparison is the live BookComparisonPanel — no best-book highlight while the crown is gated OFF', () => {
expect(src).toContain('rgba(0,212,160,.13)'); // The old inline best-book green tint/border is gone: the crown is
expect(src).toContain("borderLeft: b.best ? '2px solid var(--g-a)'"); // BOOK_CROWN_ENABLED=false, so the card makes NO best-price claim. The grid
// is now the wired BookComparisonPanel (honest breadth, no crown).
expect(src).toContain('<BookComparisonPanel');
expect(src).not.toContain("b.best ? 'rgba(0,212,160,.13)'");
}); });
it('renders kill conditions with amber border, gated on non-empty', () => { it('renders kill conditions with amber border, gated on non-empty', () => {
expect(src).toContain('hasKill'); expect(src).toContain('hasKill');
@@ -88,7 +91,9 @@ describe('Phase D — GradeResultCard (the core moment)', () => {
expect(src).toContain('KILL CONDITIONS'); expect(src).toContain('KILL CONDITIONS');
}); });
it('self-hides books / alt ladder when empty', () => { it('self-hides books / alt ladder when empty', () => {
expect(src).toContain('hasBooks'); // Book comparison is now the live BookComparisonPanel (fed by /api/books);
// it self-hides (returns null) on honest-absent. Alt ladder still gates on hasAlt.
expect(src).toContain('BookComparisonPanel');
expect(src).toContain('hasAlt'); expect(src).toContain('hasAlt');
}); });
}); });
+28 -7
View File
@@ -26,6 +26,8 @@ export interface BookComparisonProps {
side?: 'over' | 'under'; side?: 'over' | 'under';
books: BookRow[]; books: BookRow[];
savings?: number; savings?: number;
/** The panel already labels the section; drop the internal caption there. */
showHeader?: boolean;
} }
function fmt(o?: number | null) { function fmt(o?: number | null) {
@@ -33,21 +35,28 @@ function fmt(o?: number | null) {
return o > 0 ? `+${o}` : `${o}`; 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; if (!books || books.length === 0) return null;
const sideChar = side === 'under' ? 'u' : 'o';
const single = books.length === 1;
return ( return (
<div className="book-comparison" style={{ display: 'grid', gap: 8 }}> <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' }}> {showHeader && (
{player} · {stat.replace(/_/g, ' ')}{line != null ? ` ${line}` : ''} · {side} <div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
</div> {player} · {stat.replace(/_/g, ' ')} · {side}
</div>
)}
<div style={{ display: 'grid', gap: 4 }}> <div style={{ display: 'grid', gap: 4 }}>
{books.map((b) => ( {books.map((b) => (
<div <div
key={b.book} key={b.book}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '1fr auto auto', // book · that book's OWN line · price(s) · best. Books frequently sit
gap: 10, // 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', alignItems: 'center',
padding: '6px 10px', padding: '6px 10px',
borderRadius: 8, 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 {/* M3.3a — real branded book wordmark, never the capitalized raw
key ("Betmgm"). The entity-layer BookChip resolves DK/FD/MGM/… */} key ("Betmgm"). The entity-layer BookChip resolves DK/FD/MGM/… */}
<BookChip book={b.book} showName /> <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)} {fmt(b.over_odds)} / {fmt(b.under_odds)}
</span> </span>
{b.isBest ? ( {b.isBest ? (
@@ -67,6 +79,15 @@ export default function BookComparison({ player, stat, line, side = 'over', book
</div> </div>
))} ))}
</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 && ( {savings != null && savings > 0 && (
<div style={{ fontSize: 12, color: 'var(--grade-a, #00D4A0)' }}> <div style={{ fontSize: 12, color: 'var(--grade-a, #00D4A0)' }}>
Betting the best line saves ~${savings.toFixed(2)} per $100. 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 GradeBadge from '@/components/vyndr/GradeBadge';
import GradeShift from '@/components/vyndr/GradeShift'; import GradeShift from '@/components/vyndr/GradeShift';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import BookComparisonPanel from '@/components/vyndr/BookComparisonPanel';
import { type HeadshotSport } from '@/lib/playerHeadshot'; import { type HeadshotSport } from '@/lib/playerHeadshot';
import { gradeColor, gradeHex } from '@/lib/vyndrTokens'; import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
import { edgeColor, gradeGlows } from '@/lib/colorContract'; import { edgeColor, gradeGlows } from '@/lib/colorContract';
@@ -93,7 +94,6 @@ export default function GradeResultCard({
// colorContract.edgeColor helper (imported) — negative = var(--miss). DS4 // colorContract.edgeColor helper (imported) — negative = var(--miss). DS4
// introduced a local const that DS3 superseded with the shared enforcer. // introduced a local const that DS3 superseded with the shared enforcer.
const hasKill = !!d.killConditions && d.killConditions.length > 0; 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; const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0;
return ( return (
@@ -303,24 +303,12 @@ export default function GradeResultCard({
</div> </div>
)} )}
{/* 7. BOOK COMPARISON */} {/* 7. BOOK COMPARISON — the (previously-dead) BookComparison grid, now wired
{hasBooks && ( to the live /api/books feed via BookComparisonPanel. Self-fetches +
<div style={{ padding: '0 20px 16px' }}> self-hides (honest-absent). Display-only; touches no grade/line/price
<SectionHead style={{ marginBottom: 10 }}>BOOK COMPARISON</SectionHead> above. Crown stays OFF (BOOK_CROWN_ENABLED=false) — no best-price claim. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <BookComparisonPanel sport={d.sport} player={d.player} stat={d.stat} side={d.side} />
{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>
)}
{/* 8. ALT LINE LADDER (Desk) */} {/* 8. ALT LINE LADDER (Desk) */}
{hasAlt && ( {hasAlt && (