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:
@@ -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 book’s 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
|
||||
});
|
||||
});
|
||||
@@ -78,9 +78,12 @@ describe('Phase D — GradeResultCard (the core moment)', () => {
|
||||
expect(src).toContain('intel-surface');
|
||||
expect(src).toContain('grade-reveal');
|
||||
});
|
||||
it('highlights the best book with green tint + green left border', () => {
|
||||
expect(src).toContain('rgba(0,212,160,.13)');
|
||||
expect(src).toContain("borderLeft: b.best ? '2px solid var(--g-a)'");
|
||||
it('book comparison is the live BookComparisonPanel — no best-book highlight while the crown is gated OFF', () => {
|
||||
// The old inline best-book green tint/border is gone: the crown is
|
||||
// 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', () => {
|
||||
expect(src).toContain('hasKill');
|
||||
@@ -88,7 +91,9 @@ describe('Phase D — GradeResultCard (the core moment)', () => {
|
||||
expect(src).toContain('KILL CONDITIONS');
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }}>
|
||||
{showHeader && (
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||
{player} · {stat.replace(/_/g, ' ')}{line != null ? ` ${line}` : ''} · {side}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user