Read-card scan: honest no-market empty state + surface real priced lines
The Session-78 diagnosis stands: the join works, and a marketless scan rightly
shows no triplet. This makes that absence legible and points the user at what IS
priced, without fabricating a market.
PHASE 0 gate — design-check, reachability, timing, all clear. Design-check: the
bundle has the triplet's own REFUSAL language ("we'd rather show nothing than a
number we can't stand behind") as the honesty precedent, and a designed
EmptyState component whose actions give a path forward — so the empty state is
built in the established visual language, not freelanced. Reachability: the
scanner already fetches games/odds/search per selection; the snapshot is one
more public, 30s-cached fetch per sport, re-run when the sport changes.
Staleness: the snapshot rotates 5x/day and every scan re-validates the market
server-side at submit time, so a surfaced line that goes stale degrades to the
empty state on tap rather than a vanishing triplet — the stale-tap guard is
inherent, not bolted on.
Reversibility was the design constraint. The working card, price triplet, grade
adapter, valueState and the scan route are BYTE-IDENTICAL — a test asserts none
of them even reference the new empty state. Everything new lives in two added
files (lib/pricedLines.js, components/vyndr/NoMarketState.tsx) and additive
blocks in the scan page. Removing them leaves the Scan-A path untouched.
Non-fabricating by construction: indexPricedLines only keeps snapshot rows that
carry a real book price, keyed by exact player+stat via nameKey. A different
stat priced for the same player surfaces nothing for the picked stat; an
off-slate player surfaces nothing; nothing is suggested, interpolated, or
rounded to a nearest line. The empty state shows no market numbers of its own —
only real priced lines as one-tap chips, or a link to the live board when there
are none.
Framing is help, not restriction: a "PRICED TONIGHT" chip row sits under the
free-typed line input, and the scanner still accepts any player, stat and line.
Tapping a chip pre-fills the priced line and re-scans it — the market is
re-resolved server-side, so the tap either yields a real triplet or degrades to
the honest empty state.
Path forward, not a wall: a marketless scan no longer dead-ends in blank space.
It states truthfully that the board didn't price that line, keeps the grade, and
routes the user to the priced lines for that exact player+stat or to tonight's
board.
Tests 3760 passed / 303 suites, web build exit 0.
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:
@@ -17,6 +17,8 @@ import {
|
||||
trackUpgradeClicked,
|
||||
} from '@/lib/analytics';
|
||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||
import NoMarketState, { type PricedLine } from '@/components/vyndr/NoMarketState';
|
||||
import { indexPricedLines, pricedLinesFor } from '@/lib/pricedLines';
|
||||
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
||||
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
|
||||
|
||||
@@ -136,6 +138,12 @@ export default function ScanPage() {
|
||||
const [direction, setDirection] = useState<'over' | 'under'>('over');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [result, setResult] = useState<ScanResponse | null>(null);
|
||||
// Session 79 — the CURRENT snapshot's priced lines, indexed by player+stat, so
|
||||
// a marketless scan can surface REAL priced lines (never suggested/nearest).
|
||||
const [pricedIndex, setPricedIndex] = useState<Map<string, PricedLine[]> | null>(null);
|
||||
// One-tap re-scan of a surfaced priced line: bump this to re-run runScan AFTER
|
||||
// line/direction state has committed.
|
||||
const [rescanKey, setRescanKey] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
// Session 19 — tonight's players grid. Pulled from the odds proxy
|
||||
// (props array) so the chip set is real, not hard-coded. Each entry
|
||||
@@ -148,6 +156,20 @@ export default function ScanPage() {
|
||||
if (!authLoading && !user) router.replace('/signup?next=/scan');
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
// Session 79 — the REAL priced lines for the EXACT selected player+stat. Empty
|
||||
// for a player/stat the board didn't price — never suggested or interpolated.
|
||||
const pricedForSelection = useMemo(
|
||||
() => (pricedIndex && selectedPlayer && stat ? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []),
|
||||
[pricedIndex, selectedPlayer, stat],
|
||||
);
|
||||
|
||||
// One-tap re-scan after a surfaced priced line commits its line/direction.
|
||||
useEffect(() => {
|
||||
if (rescanKey === 0) return;
|
||||
void runScan();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rescanKey]);
|
||||
|
||||
// Reset stat selection when sport changes
|
||||
useEffect(() => {
|
||||
const list = SPORT_STATS[sport];
|
||||
@@ -170,6 +192,22 @@ export default function ScanPage() {
|
||||
};
|
||||
}, [sport]);
|
||||
|
||||
// Session 79 — index the current snapshot's priced lines for the surfacer.
|
||||
// /api/snapshot is public + cached 30s and is re-validated server-side at
|
||||
// scan time, so a surfaced line that goes stale degrades to the honest empty
|
||||
// state on tap rather than a vanishing triplet.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPricedIndex(null);
|
||||
fetch(`/api/snapshot/${sport.toLowerCase()}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { grades?: unknown[] } | null) => {
|
||||
if (!cancelled) setPricedIndex(indexPricedLines((data && data.grades) || []));
|
||||
})
|
||||
.catch(() => !cancelled && setPricedIndex(new Map()));
|
||||
return () => { cancelled = true; };
|
||||
}, [sport]);
|
||||
|
||||
// Session 19 — fetch tonight's players from the odds proxy. The
|
||||
// odds endpoint returns the canonical list of players who have
|
||||
// props posted, which is exactly what the scan UI should surface
|
||||
@@ -604,6 +642,27 @@ export default function ScanPage() {
|
||||
value={line}
|
||||
onChange={(e) => setLine(e.target.value)}
|
||||
/>
|
||||
{/* 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
|
||||
a real triplet. The scanner still accepts any free-typed line. */}
|
||||
{pricedForSelection.length > 0 && (
|
||||
<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' }}>
|
||||
PRICED TONIGHT:
|
||||
</span>
|
||||
{pricedForSelection.map((l, i) => (
|
||||
<button
|
||||
key={`${l.direction}-${l.line}-${i}`}
|
||||
type="button"
|
||||
onClick={() => { setLine(String(l.line)); setDirection(l.direction); }}
|
||||
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' }}
|
||||
>
|
||||
{l.direction === 'under' ? 'U' : 'O'} {l.line}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mono" style={labelStyle}>Side</label>
|
||||
@@ -775,6 +834,26 @@ export default function ScanPage() {
|
||||
onReadAnother={reset}
|
||||
/>
|
||||
|
||||
{/* Session 79 — HONEST EMPTY STATE. A marketless scan (the board never
|
||||
priced this exact player+stat+line) renders no triplet on the card
|
||||
above; this says so truthfully and surfaces the REAL priced lines
|
||||
(one-tap to a genuine triplet), or points at the live board. The
|
||||
working card/join/triplet is byte-identical — this is a separate
|
||||
block that only appears when book_odds/fair_odds came back null. */}
|
||||
{(result.book_odds == null || result.fair_odds == null) && (
|
||||
<NoMarketState
|
||||
player={selectedPlayer}
|
||||
stat={stat}
|
||||
line={line}
|
||||
pricedLines={pricedForSelection}
|
||||
onPick={(l) => {
|
||||
setLine(String(l.line));
|
||||
setDirection(l.direction);
|
||||
setRescanKey((k) => k + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Session 60 (4.3) — the model's public history on this player.
|
||||
Deferred-render: shows only when real ledger rows exist. */}
|
||||
<PriorReads player={selectedPlayer} stat={stat} />
|
||||
|
||||
Reference in New Issue
Block a user