From 2ab2eeaa7d9e2582bd777cfa87b23270a2a82c5d Mon Sep 17 00:00:00 2001 From: Kev Date: Wed, 29 Jul 2026 04:09:34 -0400 Subject: [PATCH] Wire BookComparison to the prop card (display-only, honest states) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1 --- tests/unit/bookComparisonWiring.test.js | 65 +++++++++++++++++ tests/unit/vyndrCoreScreens.test.js | 13 ++-- web/src/components/BookComparison.tsx | 35 ++++++++-- .../components/vyndr/BookComparisonPanel.tsx | 70 +++++++++++++++++++ web/src/components/vyndr/GradeResultCard.tsx | 26 ++----- 5 files changed, 179 insertions(+), 30 deletions(-) create mode 100644 tests/unit/bookComparisonWiring.test.js create mode 100644 web/src/components/vyndr/BookComparisonPanel.tsx diff --git a/tests/unit/bookComparisonWiring.test.js b/tests/unit/bookComparisonWiring.test.js new file mode 100644 index 0000000..6fac898 --- /dev/null +++ b/tests/unit/bookComparisonWiring.test.js @@ -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(''); + }); + + 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 + }); +}); diff --git a/tests/unit/vyndrCoreScreens.test.js b/tests/unit/vyndrCoreScreens.test.js index f034b85..cce8eb9 100644 --- a/tests/unit/vyndrCoreScreens.test.js +++ b/tests/unit/vyndrCoreScreens.test.js @@ -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(' { 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'); }); }); diff --git a/web/src/components/BookComparison.tsx b/web/src/components/BookComparison.tsx index a6f6dba..ad59002 100644 --- a/web/src/components/BookComparison.tsx +++ b/web/src/components/BookComparison.tsx @@ -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 (
-
- {player} · {stat.replace(/_/g, ' ')}{line != null ? ` ${line}` : ''} · {side} -
+ {showHeader && ( +
+ {player} · {stat.replace(/_/g, ' ')} · {side} +
+ )}
{books.map((b) => (
- + + {b.line != null ? `${sideChar}${b.line}` : '—'} + + {fmt(b.over_odds)} / {fmt(b.under_odds)} {b.isBest ? ( @@ -67,6 +79,15 @@ export default function BookComparison({ player, stat, line, side = 'over', book
))}
+ {/* Honest single-book state: intentional, not broken — no crown, no implied + second row, no "comparison" framing over a lone price. */} + {single && ( +
+ One book posting this prop. +
+ )} + {/* 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 && (
Betting the best line saves ~${savings.toFixed(2)} per $100. diff --git a/web/src/components/vyndr/BookComparisonPanel.tsx b/web/src/components/vyndr/BookComparisonPanel.tsx new file mode 100644 index 0000000..cf8c995 --- /dev/null +++ b/web/src/components/vyndr/BookComparisonPanel.tsx @@ -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(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 ( +
+ BOOK COMPARISON + +
+ ); +} diff --git a/web/src/components/vyndr/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx index dc90aae..6c18adb 100644 --- a/web/src/components/vyndr/GradeResultCard.tsx +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -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({
)} - {/* 7. BOOK COMPARISON */} - {hasBooks && ( -
- BOOK COMPARISON -
- {d.books.map((b, i) => ( -
- {b.name} -
- {d.side === 'Under' ? 'U' : 'O'}{b.line} - {b.odds} - {b.best && BEST} -
-
- ))} -
-
- )} + {/* 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. */} + + {/* 8. ALT LINE LADDER (Desk) */} {hasAlt && (