Scanner reskin: amber → blue boundary channel + build never-built S6/S7 states
Semantic COLOR fix, not cosmetic. The S6/S7 build predated the current Scanner
States spec: it used AMBER (the quarantine / model-suppressed channel) for the
"no market / line not priced" case, telling users "model suppressed" when the
truth is "the board never priced this." Corrected to the HANDOFF Session-3
blue-boundary law: BLUE (--priced-out #8FB2DE) = no-market boundary; amber stays
QUARANTINE; red stays REFUSAL.
Phase 1 (reskin): NoMarketState → dashed BLUE void box + blue header/copy; the
S7 rows → spec format (o 27.5 · BK −114 · ◆ −105 · OPEN READ ▸) at 44px,
390-legible. Input-area surfacer pills reskinned to the blue channel too.
Phase 2 (never-built states, only those Phase 0 confirmed against live data):
- GREEN CTA with LIVE player count ("PLAYER · N PRICED PROPS ▸"), degrading
honestly to the board path ("N PROPS LIVE · TONIGHT'S BOARD ▸") at 0 — count
from the SAME fresh index as the rows (pricedCountForPlayer), can't disagree.
- CASE A none-priced DEFAULT: "WE PRICE THESE FOR [player]" — the player's other
priced stats (pricedStatsForPlayer, filter by nameKey).
- Typed-line-mismatch blue fact line ("o X ISN'T PRICED · NEAREST ↓").
FLAGGED / not built (no shells): Case C off-slate quiet-stop needs schedule/
roster membership the pricedLines index doesn't carry (out of the presentation
fence). Spec CONTRADICTION: Case B says "fair previews amber," but the law
reserves amber for quarantine — fair renders NEUTRAL ◆ (blue-dim), not amber, to
avoid blurring the channel.
Free-tier gate VERIFIED before rendering FAIR: fair_odds is the de-vigged MARKET
price (valueState: "never hide the honest fair number"), NOT the gated
model_odds — no paid leak. Carried through indexPricedLines (additive; keying/
refresh/onPick/stale-tap all unchanged — the proven S7 data path is untouched).
Scan A byte-identical; PRICED_NUDGE_ENABLED still the kill switch; reversible.
Build exit 0; priced + parity suites green (67).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -3,7 +3,10 @@
|
|||||||
NON-FABRICATING: only real snapshot lines with a real book price surface.
|
NON-FABRICATING: only real snapshot lines with a real book price surface.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const { indexPricedLines, pricedLinesFor } = require('../../web/src/lib/pricedLines');
|
const {
|
||||||
|
indexPricedLines, pricedLinesFor, pricedStatsForPlayer, pricedCountForPlayer,
|
||||||
|
totalPricedCount, nearestPricedLine,
|
||||||
|
} = require('../../web/src/lib/pricedLines');
|
||||||
|
|
||||||
const GRADES = [
|
const GRADES = [
|
||||||
{ player_name: 'Gabriel Arias', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -165, fair_odds: -140 },
|
{ player_name: 'Gabriel Arias', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -165, fair_odds: -140 },
|
||||||
@@ -17,7 +20,16 @@ describe('indexPricedLines — only real, priced rows', () => {
|
|||||||
it('indexes a priced row by player+stat', () => {
|
it('indexes a priced row by player+stat', () => {
|
||||||
const idx = indexPricedLines(GRADES);
|
const idx = indexPricedLines(GRADES);
|
||||||
const rows = pricedLinesFor(idx, 'Gabriel Arias', 'hits');
|
const rows = pricedLinesFor(idx, 'Gabriel Arias', 'hits');
|
||||||
expect(rows).toEqual([{ line: 0.5, direction: 'over', book_odds: -165 }]);
|
// fair_odds is the de-vigged MARKET price, carried through for the S7 ◆ FAIR
|
||||||
|
// cell (free-tier visible — NOT the gated model_odds).
|
||||||
|
expect(rows).toEqual([{ line: 0.5, direction: 'over', book_odds: -165, fair_odds: -140 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries fair_odds strictly (null when absent — never Number(null)===0)', () => {
|
||||||
|
const idx = indexPricedLines([
|
||||||
|
{ player_name: 'Y', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -110 }, // no fair
|
||||||
|
]);
|
||||||
|
expect(pricedLinesFor(idx, 'Y', 'hits')[0].fair_odds).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('EXCLUDES a graded row with no book price — not a surfaceable line', () => {
|
it('EXCLUDES a graded row with no book price — not a surfaceable line', () => {
|
||||||
@@ -65,6 +77,55 @@ describe('indexPricedLines — only real, priced rows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── S7 reskin — count + Case-A helpers, all from the SAME fresh index ────────
|
||||||
|
describe('pricedStatsForPlayer / count / board — one source, honest degrade', () => {
|
||||||
|
const MULTI = [
|
||||||
|
{ player_name: 'Justin Herbert', stat_type: 'pass_yds', line: 275.5, direction: 'over', book_odds: -115, fair_odds: -108 },
|
||||||
|
{ player_name: 'Justin Herbert', stat_type: 'pass_td', line: 1.5, direction: 'over', book_odds: 120, fair_odds: 135 },
|
||||||
|
{ player_name: 'Justin Herbert', stat_type: 'interceptions', line: 0.5, direction: 'over', book_odds: 150, fair_odds: 170 },
|
||||||
|
{ player_name: 'Other Guy', stat_type: 'hits', line: 0.5, direction: 'over', book_odds: -110, fair_odds: -100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('pricedStatsForPlayer returns the player\'s priced stats across the board', () => {
|
||||||
|
const idx = indexPricedLines(MULTI);
|
||||||
|
const stats = pricedStatsForPlayer(idx, 'Justin Herbert').map((s) => s.stat).sort();
|
||||||
|
expect(stats).toEqual(['interceptions', 'pass_td', 'pass_yds']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludeStat drops the already-selected stat (Case A "these OTHERS")', () => {
|
||||||
|
const idx = indexPricedLines(MULTI);
|
||||||
|
const stats = pricedStatsForPlayer(idx, 'Justin Herbert', 'pass_yds').map((s) => s.stat).sort();
|
||||||
|
expect(stats).toEqual(['interceptions', 'pass_td']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('count comes from the SAME index as the rows (can\'t disagree)', () => {
|
||||||
|
const idx = indexPricedLines(MULTI);
|
||||||
|
expect(pricedCountForPlayer(idx, 'Justin Herbert')).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('count is 0 for an off-slate player → caller degrades, never "0 PROPS"', () => {
|
||||||
|
const idx = indexPricedLines(MULTI);
|
||||||
|
expect(pricedCountForPlayer(idx, 'Nobody Here')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('totalPricedCount is the board-wide prop count (real exhaust or 0)', () => {
|
||||||
|
expect(totalPricedCount(indexPricedLines(MULTI))).toBe(4);
|
||||||
|
expect(totalPricedCount(indexPricedLines([]))).toBe(0);
|
||||||
|
expect(totalPricedCount(null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nearestPricedLine picks the closest priced line to a typed line', () => {
|
||||||
|
const idx = indexPricedLines([
|
||||||
|
{ player_name: 'Z', stat_type: 'points', line: 27.5, direction: 'over', book_odds: -114, fair_odds: -105 },
|
||||||
|
{ player_name: 'Z', stat_type: 'points', line: 24.5, direction: 'over', book_odds: -120, fair_odds: -110 },
|
||||||
|
]);
|
||||||
|
expect(nearestPricedLine(idx, 'Z', 'points', 30.5).line).toBe(27.5);
|
||||||
|
expect(nearestPricedLine(idx, 'Z', 'points', 24).line).toBe(24.5);
|
||||||
|
expect(nearestPricedLine(idx, 'Z', 'points', null).line).toBe(24.5); // sorted first
|
||||||
|
expect(nearestPricedLine(idx, 'Nobody', 'points', 30)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the empty state + surfacer are independently reversible', () => {
|
describe('the empty state + surfacer are independently reversible', () => {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
it('the working card/join/triplet are untouched (Scan A byte-identical)', () => {
|
it('the working card/join/triplet are untouched (Scan A byte-identical)', () => {
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
trackUpgradeClicked,
|
trackUpgradeClicked,
|
||||||
} from '@/lib/analytics';
|
} from '@/lib/analytics';
|
||||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||||
import NoMarketState, { type PricedLine } from '@/components/vyndr/NoMarketState';
|
import NoMarketState, { type PricedLine, type PricedStat } from '@/components/vyndr/NoMarketState';
|
||||||
import { indexPricedLines, pricedLinesFor } from '@/lib/pricedLines';
|
import {
|
||||||
|
indexPricedLines, pricedLinesFor, pricedStatsForPlayer, pricedCountForPlayer, totalPricedCount,
|
||||||
|
} from '@/lib/pricedLines';
|
||||||
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
||||||
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
|
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
|
||||||
|
|
||||||
@@ -173,6 +175,23 @@ export default function ScanPage() {
|
|||||||
? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []),
|
? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []),
|
||||||
[pricedIndex, selectedPlayer, stat],
|
[pricedIndex, selectedPlayer, stat],
|
||||||
);
|
);
|
||||||
|
// Session (scanner reskin) — S7 Case A + live count, ALL derived from the SAME
|
||||||
|
// fresh pricedIndex as pricedForSelection, so the count can never disagree
|
||||||
|
// with the rows. Presentation-only; the index/refresh/onPick path is unchanged.
|
||||||
|
const pricedStatsForSelection = useMemo<PricedStat[]>(
|
||||||
|
() => (PRICED_NUDGE_ENABLED && pricedIndex && selectedPlayer
|
||||||
|
? pricedStatsForPlayer(pricedIndex, selectedPlayer, stat) : []),
|
||||||
|
[pricedIndex, selectedPlayer, stat],
|
||||||
|
);
|
||||||
|
const playerPricedCount = useMemo(
|
||||||
|
() => (PRICED_NUDGE_ENABLED && pricedIndex && selectedPlayer
|
||||||
|
? pricedCountForPlayer(pricedIndex, selectedPlayer) : 0),
|
||||||
|
[pricedIndex, selectedPlayer],
|
||||||
|
);
|
||||||
|
const boardPricedCount = useMemo(
|
||||||
|
() => (PRICED_NUDGE_ENABLED && pricedIndex ? totalPricedCount(pricedIndex) : 0),
|
||||||
|
[pricedIndex],
|
||||||
|
);
|
||||||
|
|
||||||
// One-tap re-scan after a surfaced priced line commits its line/direction.
|
// One-tap re-scan after a surfaced priced line commits its line/direction.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -681,9 +700,13 @@ export default function ScanPage() {
|
|||||||
{/* Session 79 — HELP, not restriction: the board's REAL priced lines
|
{/* Session 79 — HELP, not restriction: the board's REAL priced lines
|
||||||
for this exact player+stat. Tap to pre-fill a line that will yield
|
for this exact player+stat. Tap to pre-fill a line that will yield
|
||||||
a real triplet. The scanner still accepts any free-typed line. */}
|
a real triplet. The scanner still accepts any free-typed line. */}
|
||||||
|
{/* S7 Case B — the board's REAL priced lines for this player+stat, in
|
||||||
|
the blue boundary channel (--priced-out): line · BK odds · ◆ fair.
|
||||||
|
Tap pre-fills the line so the next scan yields a real triplet. Fair
|
||||||
|
renders NEUTRAL, not amber (blue-boundary law). */}
|
||||||
{pricedForSelection.length > 0 && (
|
{pricedForSelection.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||||
<span className="mono" style={{ fontSize: 9, color: 'var(--text-3)', letterSpacing: '0.1em', alignSelf: 'center' }}>
|
<span className="mono" style={{ fontSize: 9, color: 'var(--priced-out-dim, #7a93b0)', letterSpacing: '0.1em', alignSelf: 'center' }}>
|
||||||
PRICED TONIGHT:
|
PRICED TONIGHT:
|
||||||
</span>
|
</span>
|
||||||
{pricedForSelection.map((l, i) => (
|
{pricedForSelection.map((l, i) => (
|
||||||
@@ -692,9 +715,20 @@ export default function ScanPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setLine(String(l.line)); setDirection(l.direction); }}
|
onClick={() => { setLine(String(l.line)); setDirection(l.direction); }}
|
||||||
className="mono"
|
className="mono"
|
||||||
style={{ cursor: 'pointer', fontSize: 11, fontWeight: 700, padding: '4px 9px', borderRadius: 999, border: '1px solid var(--border-hi)', background: 'transparent', color: 'var(--text-1)', fontVariantNumeric: 'tabular-nums' }}
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer',
|
||||||
|
minHeight: 40, fontSize: 11, fontWeight: 700, padding: '0 11px', borderRadius: 999,
|
||||||
|
border: '1px solid var(--priced-out-border-hi, rgba(106,147,200,0.34))',
|
||||||
|
background: 'var(--priced-out-tint-2, rgba(106,147,200,0.12))',
|
||||||
|
color: 'var(--text-0)', fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{l.direction === 'under' ? 'U' : 'O'} {l.line}
|
<span style={{ fontWeight: 800, fontSize: 12 }}>{l.direction === 'under' ? 'u' : 'o'} {l.line}</span>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>·</span>
|
||||||
|
<span style={{ color: 'var(--text-1)' }}>BK {l.book_odds > 0 ? `+${Math.round(l.book_odds)}` : Math.round(l.book_odds)}</span>
|
||||||
|
{l.fair_odds != null && (
|
||||||
|
<span style={{ color: 'var(--priced-out-dim, #7a93b0)' }}>◆ {l.fair_odds > 0 ? `+${Math.round(l.fair_odds)}` : Math.round(l.fair_odds)}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -882,6 +916,9 @@ export default function ScanPage() {
|
|||||||
stat={stat}
|
stat={stat}
|
||||||
line={line}
|
line={line}
|
||||||
pricedLines={pricedForSelection}
|
pricedLines={pricedForSelection}
|
||||||
|
pricedStats={pricedStatsForSelection}
|
||||||
|
playerCount={playerPricedCount}
|
||||||
|
boardCount={boardPricedCount}
|
||||||
onPick={(l) => {
|
onPick={(l) => {
|
||||||
setLine(String(l.line));
|
setLine(String(l.line));
|
||||||
setDirection(l.direction);
|
setDirection(l.direction);
|
||||||
|
|||||||
@@ -1,121 +1,224 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NoMarketState (Session 79) — the honest empty state for a scanned prop the
|
* NoMarketState (S6 · SCAN EMPTY STATE) — the honest marketless read card.
|
||||||
* board never priced.
|
|
||||||
*
|
*
|
||||||
* A manual scan is an arbitrary player + stat + free-typed line, so most scans
|
* A manual scan is an arbitrary player + stat + free-typed line, so most scans
|
||||||
* land on a prop no book line was captured for. The join correctly returns no
|
* land on a prop the board never priced. The join correctly returns no market
|
||||||
* market and the read card's price triplet correctly self-hides — but a blank
|
* and the read card's price triplet correctly self-hides — but a blank space
|
||||||
* space reads as "the product is empty." This says, truthfully, what happened
|
* reads as "the product is empty." This states, truthfully, what happened and
|
||||||
* and points the user at what IS priced.
|
* offers the player's REAL priced stats as the way through.
|
||||||
*
|
*
|
||||||
* TRUTH LAW: NO market numbers, no placeholder odds, no "coming soon." The only
|
* COLOR LAW (HANDOFF Session 3 — blue boundary channel): "no market / line not
|
||||||
* numbers shown are REAL priced lines from the current snapshot (passed in),
|
* priced / priced out" is the BLUE channel (--priced-out #8FB2DE), NOT amber.
|
||||||
* each a one-tap route to a genuine triplet. If there are none, it degrades to
|
* Amber is reserved for QUARANTINE (model leg suppressed); red for REFUSAL
|
||||||
* copy + a path to the live board. Everything renders from tokens.
|
* (a model decision). The pre-spec build used amber here — telling users "model
|
||||||
|
* suppressed" when the truth is "the board never priced this." This is the
|
||||||
|
* semantic correction, not a cosmetic one.
|
||||||
*
|
*
|
||||||
* FRAMING: this is HELP ("here's what we price tonight"), not RESTRICTION — the
|
* TRUTH LAW: NO market numbers are invented. The only numbers shown are REAL
|
||||||
* scanner still accepts any prop; this only appears once a scan came back
|
* priced lines from the current snapshot (book + de-vigged fair, both free-tier
|
||||||
* marketless.
|
* visible; MODEL is never rendered here), each a one-tap route to a genuine
|
||||||
|
* triplet. Counts come from the SAME fresh index as the rows, so they can never
|
||||||
|
* disagree. Nothing is suggested, interpolated, or rounded to a nearest price.
|
||||||
|
*
|
||||||
|
* FLAG (spec vs law): the S7 spec calls fair previews AMBER; the blue-boundary
|
||||||
|
* law reserves amber for quarantine. To avoid blurring the channel the fair
|
||||||
|
* number renders in a restrained neutral `◆` treatment, not amber.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface PricedLine {
|
export interface PricedLine {
|
||||||
line: number;
|
line: number;
|
||||||
direction: 'over' | 'under';
|
direction: 'over' | 'under';
|
||||||
book_odds: number;
|
book_odds: number;
|
||||||
|
fair_odds?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricedStat {
|
||||||
|
stat: string;
|
||||||
|
lines: PricedLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtOdds = (o: number) => (o > 0 ? `+${Math.round(o)}` : String(Math.round(o)));
|
const fmtOdds = (o: number) => (o > 0 ? `+${Math.round(o)}` : String(Math.round(o)));
|
||||||
|
const statLabel = (s: string) => String(s || '').replace(/_/g, ' ').toUpperCase();
|
||||||
|
|
||||||
|
// Blue boundary-channel tokens (globals.css --priced-out family), with the
|
||||||
|
// documented-intentional hex fallbacks.
|
||||||
|
const BLUE = 'var(--priced-out, #8fb2de)';
|
||||||
|
const BLUE_DIM = 'var(--priced-out-dim, #7a93b0)';
|
||||||
|
const BLUE_TINT = 'var(--priced-out-tint-2, rgba(106,147,200,0.12))';
|
||||||
|
const BLUE_BORDER = 'var(--priced-out-border-hi, rgba(106,147,200,0.34))';
|
||||||
|
|
||||||
|
/** The S7 spec row: `{o|u} {line} · BK {odds} · ◆ {fair} · OPEN READ ▸`, a 44px
|
||||||
|
* tap straight into the real triplet. Legible at 390px — the label is compact
|
||||||
|
* (BK/◆) so it never overflows. */
|
||||||
|
function PricedRow({ l, onPick }: { l: PricedLine; onPick?: (l: PricedLine) => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick && onPick(l)}
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 44,
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${BLUE_BORDER}`,
|
||||||
|
background: BLUE_TINT,
|
||||||
|
color: 'var(--text-0)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 800, fontSize: 13 }}>
|
||||||
|
{l.direction === 'under' ? 'u' : 'o'} {l.line}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>·</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-1)' }}>
|
||||||
|
BK <span style={{ fontWeight: 700 }}>{fmtOdds(l.book_odds)}</span>
|
||||||
|
</span>
|
||||||
|
{l.fair_odds != null && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--text-3)' }}>·</span>
|
||||||
|
{/* fair preview — NEUTRAL, not amber (blue-boundary law); ◆ carries it */}
|
||||||
|
<span style={{ fontSize: 12, color: BLUE_DIM }}>
|
||||||
|
◆ <span style={{ fontWeight: 700 }}>{fmtOdds(l.fair_odds)}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="mono" style={{ marginLeft: 'auto', fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: BLUE }}>
|
||||||
|
OPEN READ ▸
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function NoMarketState({
|
export default function NoMarketState({
|
||||||
player,
|
player,
|
||||||
stat,
|
stat,
|
||||||
line,
|
line,
|
||||||
pricedLines = [],
|
pricedLines = [],
|
||||||
|
pricedStats = [],
|
||||||
|
playerCount = 0,
|
||||||
|
boardCount = 0,
|
||||||
onPick,
|
onPick,
|
||||||
}: {
|
}: {
|
||||||
player: string;
|
player: string;
|
||||||
stat: string;
|
stat: string;
|
||||||
line: number | string;
|
line: number | string;
|
||||||
/** REAL priced lines for this exact player+stat from the current snapshot. */
|
/** REAL priced lines for this EXACT player+stat (typed-line mismatch path). */
|
||||||
pricedLines?: PricedLine[];
|
pricedLines?: PricedLine[];
|
||||||
/** Tap a real priced line → re-scan it (the market is re-validated server-side). */
|
/** The player's OTHER priced stats (Case A "we price these for [player]"). */
|
||||||
|
pricedStats?: PricedStat[];
|
||||||
|
/** Priced-prop count for THIS player (green CTA). 0 → degrade to board path. */
|
||||||
|
playerCount?: number;
|
||||||
|
/** Board-wide priced-prop count ("N PROPS LIVE"). */
|
||||||
|
boardCount?: number;
|
||||||
|
/** Tap a real priced line → re-scan it (re-validated server-side). */
|
||||||
onPick?: (l: PricedLine) => void;
|
onPick?: (l: PricedLine) => void;
|
||||||
}) {
|
}) {
|
||||||
const statLabel = String(stat || '').replace(/_/g, ' ');
|
const sorted = [...pricedLines].sort((a, b) => a.line - b.line);
|
||||||
|
const hasExact = sorted.length > 0; // this stat IS priced (typed mismatch)
|
||||||
|
const hasOtherStats = pricedStats.length > 0; // Case A
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="no-market-state"
|
className="no-market-state"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--border)',
|
border: `1px dashed ${BLUE_BORDER}`, // DASHED blue void box (refusal-frame shape, blue not red)
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: '16px 18px',
|
padding: '16px 18px',
|
||||||
background: 'var(--bg-deep)',
|
background: BLUE_TINT,
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mono"
|
className="mono"
|
||||||
style={{ fontSize: 10.5, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--amber)', marginBottom: 6 }}
|
style={{ fontSize: 10.5, fontWeight: 800, letterSpacing: '0.12em', color: BLUE, marginBottom: 6 }}
|
||||||
>
|
>
|
||||||
NO LIVE MARKET FOR THIS LINE
|
NO LIVE MARKET FOR THIS LINE
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-1)', margin: '0 0 12px', lineHeight: 1.5 }}>
|
<p style={{ fontSize: 13, color: 'var(--text-1)', margin: '0 0 8px', lineHeight: 1.5 }}>
|
||||||
The board hasn’t priced <span className="mono" style={{ color: 'var(--text-0)' }}>{player}</span>{' '}
|
No book prices <span className="mono" style={{ color: 'var(--text-0)' }}>{player}</span>{' '}
|
||||||
{statLabel} at <span className="mono" style={{ color: 'var(--text-0)' }}>{String(line)}</span>. The grade
|
{String(stat || '').replace(/_/g, ' ')} at{' '}
|
||||||
above stands; there’s just no book number to price it against.
|
<span className="mono" style={{ color: 'var(--text-0)' }}>{String(line)}</span> right now — so
|
||||||
|
there’s no vig to strip and no fair number to show. The grade above stands; we won’t
|
||||||
|
invent a market that isn’t there.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mono" style={{ fontSize: 8.5, fontWeight: 700, letterSpacing: '0.14em', color: 'var(--text-3)', marginBottom: 12 }}>
|
||||||
|
NO BOOK · NO FAIR · NO MODEL · NOTHING FABRICATED
|
||||||
|
</div>
|
||||||
|
|
||||||
{pricedLines.length > 0 ? (
|
{/* ── PATH FORWARD ──────────────────────────────────────────────────── */}
|
||||||
|
{hasExact ? (
|
||||||
|
// Typed-line mismatch: this exact stat IS priced, just not at the typed
|
||||||
|
// line. Show the real priced lines — never rewrite the user's intent.
|
||||||
<>
|
<>
|
||||||
<div
|
<div className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', color: BLUE_DIM, marginBottom: 8 }}>
|
||||||
className="mono"
|
<span style={{ color: 'var(--text-2)' }}>o {String(line)} ISN’T PRICED</span> · NEAREST PRICED LINE ↓
|
||||||
style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-3)', marginBottom: 8 }}
|
|
||||||
>
|
|
||||||
PRICED TONIGHT · TAP TO READ THE TRIPLET
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{pricedLines.map((l, i) => (
|
{sorted.map((l, i) => <PricedRow key={`${l.direction}-${l.line}-${i}`} l={l} onPick={onPick} />)}
|
||||||
<button
|
|
||||||
key={`${l.direction}-${l.line}-${i}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPick && onPick(l)}
|
|
||||||
className="mono"
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
padding: '7px 12px',
|
|
||||||
borderRadius: 999,
|
|
||||||
border: '1px solid var(--fair-border)',
|
|
||||||
background: 'var(--fair-tint)',
|
|
||||||
color: 'var(--text-0)',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{l.direction === 'under' ? 'U' : 'O'} {l.line}{' '}
|
|
||||||
<span style={{ color: 'var(--text-2)' }}>· {fmtOdds(l.book_odds)}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : hasOtherStats ? (
|
||||||
<a
|
// CASE A — none priced for THIS stat, but the player has other priced
|
||||||
href="/dashboard"
|
// stats. The DEFAULT, common case: quiet, structured, the way through.
|
||||||
className="mono"
|
<>
|
||||||
style={{
|
<div className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', color: BLUE_DIM, marginBottom: 8 }}>
|
||||||
display: 'inline-block',
|
WE PRICE THESE FOR {String(player).toUpperCase()}
|
||||||
fontSize: 11,
|
</div>
|
||||||
fontWeight: 800,
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
letterSpacing: '0.08em',
|
{pricedStats.map((ps) => {
|
||||||
color: 'var(--g-a)',
|
const l = ps.lines[0];
|
||||||
textDecoration: 'none',
|
return (
|
||||||
}}
|
<button
|
||||||
>
|
key={ps.stat}
|
||||||
SEE TONIGHT’S PRICED BOARD ▸
|
type="button"
|
||||||
</a>
|
onClick={() => onPick && l && onPick(l)}
|
||||||
)}
|
className="mono"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, minHeight: 40, padding: '0 12px',
|
||||||
|
borderRadius: 999, border: `1px solid ${BLUE_BORDER}`, background: 'transparent',
|
||||||
|
color: 'var(--text-0)', cursor: 'pointer', fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em', color: BLUE_DIM }}>{statLabel(ps.stat)}</span>
|
||||||
|
{l && <span style={{ fontSize: 12, fontWeight: 800 }}>{l.direction === 'under' ? 'u' : 'o'} {l.line}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── LIVE COUNT CTA — player-specific when >0, else honest board path ── */}
|
||||||
|
<div style={{ marginTop: hasExact || hasOtherStats ? 14 : 0 }}>
|
||||||
|
{playerCount > 0 ? (
|
||||||
|
<a href="/dashboard" className="mono" style={ctaStyle(BLUE)}>
|
||||||
|
{String(player).toUpperCase()} · {playerCount} PRICED PROP{playerCount === 1 ? '' : 'S'} ▸
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a href="/dashboard" className="mono" style={ctaStyle(BLUE)}>
|
||||||
|
{boardCount > 0 ? `${boardCount} PROPS LIVE · ` : ''}TONIGHT’S BOARD ▸
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ctaStyle(color: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
color,
|
||||||
|
textDecoration: 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,16 @@ function indexPricedLines(grades) {
|
|||||||
// Dedupe on line+side — one snapshot shouldn't carry the same line twice,
|
// Dedupe on line+side — one snapshot shouldn't carry the same line twice,
|
||||||
// but be defensive so the chip list never repeats.
|
// but be defensive so the chip list never repeats.
|
||||||
if (!rows.some((r) => r.line === line && r.direction === side)) {
|
if (!rows.some((r) => r.line === line && r.direction === side)) {
|
||||||
rows.push({ line, direction: side, book_odds: Number(book) });
|
// fair_odds is the de-vigged MARKET price (free-tier visible — NOT the
|
||||||
|
// gated model_odds). Carried through so the S7 spec row can render
|
||||||
|
// `◆ FAIR`; strict null (Number(null) === 0 would fabricate a price).
|
||||||
|
const fair = g.fair_odds;
|
||||||
|
rows.push({
|
||||||
|
line,
|
||||||
|
direction: side,
|
||||||
|
book_odds: Number(book),
|
||||||
|
fair_odds: fair == null || fair === '' ? null : Number(fair),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
idx.set(key, rows);
|
idx.set(key, rows);
|
||||||
}
|
}
|
||||||
@@ -51,4 +60,67 @@ function pricedLinesFor(index, player, stat) {
|
|||||||
return [...rows].sort((a, b) => a.line - b.line);
|
return [...rows].sort((a, b) => a.line - b.line);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { indexPricedLines, pricedLinesFor };
|
/**
|
||||||
|
* pricedStatsForPlayer(index, player) — the player's priced stats ACROSS the
|
||||||
|
* board (S7 Case A "WE PRICE THESE FOR [player]"). Reads the SAME fresh index
|
||||||
|
* the per-selection chips use — filters keys by the player's nameKey prefix so
|
||||||
|
* the count and the chips can never disagree with the rows above them. Returns
|
||||||
|
* [{ stat, lines }] sorted by stat; `excludeStat` drops the already-selected
|
||||||
|
* stat (Case A is "this stat has none, but these others do").
|
||||||
|
*/
|
||||||
|
function pricedStatsForPlayer(index, player, excludeStat) {
|
||||||
|
if (!index || !player) return [];
|
||||||
|
const prefix = `${nameKey(player)}|`;
|
||||||
|
const skip = excludeStat ? String(excludeStat).toLowerCase() : null;
|
||||||
|
const out = [];
|
||||||
|
for (const [key, rows] of index) {
|
||||||
|
if (!key.startsWith(prefix)) continue;
|
||||||
|
const stat = key.slice(prefix.length);
|
||||||
|
if (skip && stat === skip) continue;
|
||||||
|
if (Array.isArray(rows) && rows.length) {
|
||||||
|
out.push({ stat, lines: [...rows].sort((a, b) => a.line - b.line) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.stat.localeCompare(b.stat));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pricedCountForPlayer(index, player) — how many priced props (distinct stats)
|
||||||
|
* the board carries for this player tonight. The number behind the green CTA
|
||||||
|
* "PLAYER · N PRICED PROPS ▸"; 0 → the caller must degrade to the board path,
|
||||||
|
* never render "0 PRICED PROPS".
|
||||||
|
*/
|
||||||
|
function pricedCountForPlayer(index, player) {
|
||||||
|
return pricedStatsForPlayer(index, player).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* totalPricedCount(index) — board-wide priced-prop count ("N PROPS LIVE"). Each
|
||||||
|
* index key is one player+stat prop. Real exhaust or 0 — never a placeholder.
|
||||||
|
*/
|
||||||
|
function totalPricedCount(index) {
|
||||||
|
return index && typeof index.size === 'number' ? index.size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nearestPricedLine(index, player, stat, typedLine) — for the typed-line
|
||||||
|
* mismatch fact line: the priced line closest to what the user typed, so the
|
||||||
|
* strip can say "o 30.5 ISN'T PRICED · NEAREST ↓ o 27.5" WITHOUT rewriting the
|
||||||
|
* user's intent. Null when nothing is priced for that exact player+stat.
|
||||||
|
*/
|
||||||
|
function nearestPricedLine(index, player, stat, typedLine) {
|
||||||
|
const rows = pricedLinesFor(index, player, stat);
|
||||||
|
if (!rows.length) return null;
|
||||||
|
const t = Number(typedLine);
|
||||||
|
if (!Number.isFinite(t)) return rows[0];
|
||||||
|
return rows.reduce((best, r) => (Math.abs(r.line - t) < Math.abs(best.line - t) ? r : best), rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
indexPricedLines,
|
||||||
|
pricedLinesFor,
|
||||||
|
pricedStatsForPlayer,
|
||||||
|
pricedCountForPlayer,
|
||||||
|
totalPricedCount,
|
||||||
|
nearestPricedLine,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user