From 5d19660f8e4c0e4748feb9422eba654a31ec6489 Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 11 Jul 2026 00:59:17 -0400 Subject: [PATCH] =?UTF-8?q?Session=20E=20(night2):=20Phase=204=20=E2=80=94?= =?UTF-8?q?=20scan=20+=20parlay=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4.1 ROOT CAUSE of 'Ohtani returns nothing': no backend /api/players/search existed (MLB 404'd; NBA/WNBA hit the offline Python service). New Express route + mlbStatsAdapter.matchPlayers — canonical nameKey fuzzy match (exact > last-name prefix > folded substring). LIVE-VERIFIED vs the real 1,299-player list: Ohtani / Aaron Judge / Sánchez / sanchez / Chisholm Jr all resolve; accented and unaccented return identical results. Non-MLB matches the platform's cached names (rosterlogs + grades), cache-only. 4.2 Reveal choreography per §7: analyzing steps → DECLASSIFIED stamp → 90ms-staggered context panels (entrance floors visible per the Phase-0 rule); prefers-reduced-motion skips straight to the card. 4.3 PRIOR READS chips on scan results — the model's public ledger history for the player (deferred-render, outcomes + pending, never invented). /api/ledger/model gains ?player= on entries. 4.4 Parlay Lab: humanized stat labels via the ONE shared formatter (lib/gradeAdapter.statLabel); 1-leg provisional grade ('Leg grade: B — add a leg for the combined read'); discoverable entry — Nav 'Parlay Lab' item opens the drawer via window.__openParlay, and the open drawer now renders an honest empty state at 0 legs. Co-Authored-By: Claude Fable 5 --- src/app.js | 2 + src/routes/ledger.js | 2 + src/routes/players.js | 74 ++++++++++++++++++++ src/services/adapters/mlbStatsAdapter.js | 41 +++++++++++ tests/unit/mlbStatsAdapter.test.js | 23 ++++++ web/src/app/api/players/search/route.ts | 21 +++--- web/src/app/globals.css | 30 ++++++++ web/src/app/scan/page.tsx | 5 ++ web/src/components/Nav.tsx | 12 +++- web/src/components/vyndr/ParlayPanel.tsx | 30 ++++++-- web/src/components/vyndr/PriorReads.tsx | 68 ++++++++++++++++++ web/src/components/vyndr/ProcessingGrade.tsx | 43 +++++++++--- 12 files changed, 325 insertions(+), 26 deletions(-) create mode 100644 src/routes/players.js create mode 100644 web/src/components/vyndr/PriorReads.tsx diff --git a/src/app.js b/src/app.js index 8c41c4b..0027e39 100644 --- a/src/app.js +++ b/src/app.js @@ -128,6 +128,8 @@ app.use('/api/preferences', require('./routes/preferences')); app.use('/api/stripe', stripeRoutes); app.use('/api/stats', statsRoutes); app.use('/api/props', propsRoutes); +// Session 60 (night2/E) — the scan search box's canonical player resolver. +app.use('/api/players', require('./routes/players')); app.use('/api/waitlist', waitlistRoutes); app.use('/api/pipeline', pipelineRoutes); app.use('/api/share-card', shareCardRoutes); diff --git a/src/routes/ledger.js b/src/routes/ledger.js index 204b244..9ce7a83 100644 --- a/src/routes/ledger.js +++ b/src/routes/ledger.js @@ -92,6 +92,8 @@ router.get('/model', async (req, res) => { .select(ROW_COLUMNS) .is('user_id', null); q = applyFilters(q, req); + // Session 60 (night2/E) — PRIOR READS: a player's own public history. + if (req.query.player) q = q.eq('player_key', nameKey(String(req.query.player).slice(0, 60))); const { data, error } = await q .order('graded_at', { ascending: false }) .limit(limit); diff --git a/src/routes/players.js b/src/routes/players.js new file mode 100644 index 0000000..7c76045 --- /dev/null +++ b/src/routes/players.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * GET /api/players/search (Session 60, night2/E — audit fix 4.1). + * + * The scan search box's canonical resolver. Before this route existed, MLB + * search 404'd at Express and NBA/WNBA hit the (usually offline) Python + * service — "Ohtani" returned nothing while his tile sat on the page. + * + * MLB: fuzzy match against the cached statsapi player list (free, 24h + * cache) — case/diacritic-insensitive, nickname/suffix-aware via nameKey. + * Other sports: cache-only match against the names the platform already + * knows (rosterlogs blob + tonight's graded slate). Never an upstream call + * for non-MLB; empty is a valid answer. + */ + +const express = require('express'); +const { createRateLimit } = require('../middleware/rateLimit'); +const { nameKey } = require('../utils/playerName'); + +const router = express.Router(); +router.use(createRateLimit({ windowMs: 60_000, max: 60 })); + +async function cachedNames(sport) { + const { cacheGet } = require('../utils/redis'); + const out = new Map(); + try { + const blob = await cacheGet(`rosterlogs:${sport}`); + for (const p of Array.isArray(blob) ? blob : []) { + if (p && p.name) out.set(nameKey(p.name), { full_name: p.name, team: p.team || null }); + } + } catch { /* cache-only, degrade */ } + try { + const env = await cacheGet(`grades:${sport}`); + for (const g of (env && env.grades) || []) { + const n = g.player || g.player_name; + if (n && !out.has(nameKey(n))) out.set(nameKey(n), { full_name: n, team: g.team || null }); + } + } catch { /* cache-only, degrade */ } + return [...out.values()]; +} + +router.get('/search', async (req, res) => { + const sport = String(req.query.sport || 'NBA').toUpperCase(); + const q = String(req.query.q || '').trim(); + if (q.length < 2) return res.json({ players: [] }); + + try { + if (sport === 'MLB') { + const { searchPlayers } = require('../services/adapters/mlbStatsAdapter'); + const hits = await searchPlayers(q, { limit: 12 }); + return res.json({ + players: hits.map((h) => ({ id: String(h.id), full_name: h.fullName, team: h.team || undefined, position: h.position || undefined })), + }); + } + // NBA/WNBA/soccer — the names the platform already carries (cache-only). + const names = await cachedNames(sport.toLowerCase()); + const qKey = nameKey(q); + const qLast = qKey.split(' ').pop(); + const players = names + .filter((p) => { + const k = nameKey(p.full_name); + return k === qKey || k.includes(qKey) || (qLast.length >= 3 && k.split(' ').some((w) => w.startsWith(qLast))); + }) + .slice(0, 12) + .map((p, i) => ({ id: `${sport}-${i}-${nameKey(p.full_name)}`, full_name: p.full_name, team: p.team || undefined })); + return res.json({ players }); + } catch (err) { + console.error('[players/search]', err.message); + return res.json({ players: [] }); + } +}); + +module.exports = router; diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index 2bc2d44..200b399 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -144,6 +144,45 @@ const { nameKey } = require('../../utils/playerName'); * requires a UNIQUE same-last-name + same-first-initial candidate; anything * ambiguous returns null (a missing profile beats another player's log). */ +/** + * Session 60 (night2/E, audit fix 4.1) — fuzzy MULTI-match against the + * canonical list. Pure: rank = exact nameKey > last-name prefix > folded + * substring. Case/diacritic-insensitive via nameKey's folding, so + * "ohtani", "Sánchez", "sanchez", "Chisholm Jr" all resolve. + */ +function matchPlayers(people, query, limit = 12) { + const qKey = nameKey(query); + if (!qKey) return []; + const qLast = qKey.split(' ').pop(); + const scored = []; + for (const p of people || []) { + const k = nameKey(p.fullName); + if (!k) continue; + let score = null; + if (k === qKey) score = 0; + else if (k.split(' ').some((w) => w.startsWith(qLast)) && qLast.length >= 3) score = 1; + else if (k.includes(qKey)) score = 2; + if (score == null) continue; + scored.push({ score, p }); + } + scored.sort((a, b) => a.score - b.score); + return scored.slice(0, limit).map(({ p }) => ({ + id: p.id, + fullName: p.fullName, + team: p.currentTeam?.name ?? null, + position: p.primaryPosition?.abbreviation ?? null, + })); +} + +/** Multi-result fuzzy search (scan search box). Cached list, free API. */ +async function searchPlayers(query, opts = {}) { + const season = opts.season || DEFAULT_SEASON; + const url = `${BASE}/sports/1/players?season=${season}`; + const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); + const people = (data && Array.isArray(data.people)) ? data.people : []; + return matchPlayers(people, query, opts.limit || 12); +} + async function searchPlayer(name, season = DEFAULT_SEASON) { const targetKey = nameKey(name); if (!targetKey) return null; @@ -248,6 +287,8 @@ module.exports = { getSeasonAverages, getBatterVsPitcher, searchPlayer, + searchPlayers, + matchPlayers, getPlayerStats, getTeams, resolveTeam, diff --git a/tests/unit/mlbStatsAdapter.test.js b/tests/unit/mlbStatsAdapter.test.js index 00d32c6..1c29158 100644 --- a/tests/unit/mlbStatsAdapter.test.js +++ b/tests/unit/mlbStatsAdapter.test.js @@ -176,3 +176,26 @@ describe('searchPlayer — canonical nameKey resolution', () => { expect(hit).toBeNull(); }); }); + +// Session 60 (night2/E, audit 4.1) — the scan box fuzzy matcher. +describe('matchPlayers — fuzzy search (pure)', () => { + const LIST = [ + { id: 1, fullName: 'Shohei Ohtani', currentTeam: { name: 'Los Angeles Dodgers' }, primaryPosition: { abbreviation: 'DH' } }, + { id: 2, fullName: 'Aaron Judge', currentTeam: { name: 'New York Yankees' }, primaryPosition: { abbreviation: 'RF' } }, + { id: 3, fullName: 'Cristopher Sanchez', currentTeam: { name: 'Philadelphia Phillies' }, primaryPosition: { abbreviation: 'P' } }, + { id: 4, fullName: 'Jazz Chisholm Jr.', currentTeam: { name: 'New York Yankees' }, primaryPosition: { abbreviation: '3B' } }, + ]; + const { matchPlayers } = adapter; + + test.each([ + ['ohtani', 1], ['Aaron Judge', 2], ['Sánchez', 3], ['sanchez', 3], ['Chisholm Jr', 4], ['jazz chisholm', 4], + ])('"%s" resolves', (q, id) => { + const hits = matchPlayers(LIST, q); + expect(hits.length).toBeGreaterThan(0); + expect(hits[0].id).toBe(id); + }); + + test('sub-2-char garbage resolves nothing', () => { + expect(matchPlayers(LIST, '')).toEqual([]); + }); +}); diff --git a/web/src/app/api/players/search/route.ts b/web/src/app/api/players/search/route.ts index 2e1e879..9a9360d 100644 --- a/web/src/app/api/players/search/route.ts +++ b/web/src/app/api/players/search/route.ts @@ -20,16 +20,19 @@ export async function GET(req: NextRequest) { if (q.length < 2) return NextResponse.json({ players: [] }); try { - // NBA/WNBA use the nba_api wrapper service; MLB falls back to the main backend. - const url = - sport === 'MLB' - ? `${BACKEND_URL}/api/players/search?sport=MLB&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}` - : `${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`; + // Session 60 (night2/E, audit 4.1) — Express is the canonical resolver + // for EVERY sport now (nameKey fuzzy match; MLB = full statsapi list, + // others = the platform's cached names). The Python NBA service is a + // best-effort second try only when the canonical index has nothing. + const url = `${BACKEND_URL}/api/players/search?sport=${encodeURIComponent(sport)}&q=${encodeURIComponent(q)}${gameId ? `&game_id=${encodeURIComponent(gameId)}` : ''}`; - const res = await fetch(url, { headers: { Accept: 'application/json' } }); - if (!res.ok) return NextResponse.json({ players: [] }); - - const data = await res.json().catch(() => ({})); + let res = await fetch(url, { headers: { Accept: 'application/json' } }); + let data = res.ok ? await res.json().catch(() => ({})) : {}; + const canonical = Array.isArray((data as { players?: unknown[] }).players) ? (data as { players: unknown[] }).players : []; + if (canonical.length === 0 && sport !== 'MLB') { + res = await fetch(`${NBA_SERVICE}/players/search?name=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' } }).catch(() => new Response(null, { status: 502 })); + data = res.ok ? await res.json().catch(() => ({})) : {}; + } const rawPlayers: unknown[] = Array.isArray((data as { results?: unknown[] }).results) ? (data as { results: unknown[] }).results : Array.isArray((data as { players?: unknown[] }).players) diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 9f9ddd6..0dc18c5 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1293,3 +1293,33 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; } .gl-full:not(.gl-expanded) { display: none !important; } .gl-full.gl-expanded { margin-top: 10px; } } + +/* ============================================================ + Session 60 (night2/E) — §7 reveal choreography. + Stamp slam + 90ms staggered context panels. Entrance keyframes floor at + a VISIBLE state (Phase-0 rule: a paused frame is never invisible), and + reduced-motion kills the choreography entirely (ProcessingGrade also + skips straight to the card). + ============================================================ */ +@keyframes stamp-in { + 0% { opacity: .5; transform: rotate(-7deg) scale(1.7); } + 60% { opacity: 1; transform: rotate(-7deg) scale(0.96); } + 100% { opacity: 1; transform: rotate(-7deg) scale(1); } +} +.stamp-in { animation: stamp-in .34s cubic-bezier(.2,1.6,.4,1) both; } + +@keyframes panel-in { + from { opacity: .55; transform: translateY(7px); } + to { opacity: 1; transform: translateY(0); } +} +.grade-reveal > div > * { animation: panel-in .3s ease-out both; } +.grade-reveal > div > *:nth-child(2) { animation-delay: 90ms; } +.grade-reveal > div > *:nth-child(3) { animation-delay: 180ms; } +.grade-reveal > div > *:nth-child(4) { animation-delay: 270ms; } +.grade-reveal > div > *:nth-child(5) { animation-delay: 360ms; } +.grade-reveal > div > *:nth-child(6) { animation-delay: 450ms; } +.grade-reveal > div > *:nth-child(7) { animation-delay: 540ms; } +.grade-reveal > div > *:nth-child(n+8) { animation-delay: 630ms; } +@media (prefers-reduced-motion: reduce) { + .stamp-in, .grade-reveal > div > * { animation: none !important; } +} diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index 62050e5..c98f596 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import ProcessingGrade from '@/components/vyndr/ProcessingGrade'; +import PriorReads from '@/components/vyndr/PriorReads'; import { AccuracyBadge } from '@/components/vyndr'; import type { GradeResultData } from '@/components/vyndr/GradeResultCard'; import { mapScanToGradeResult } from '@/lib/gradeAdapter'; @@ -762,6 +763,10 @@ export default function ScanPage() { onReadAnother={reset} /> + {/* Session 60 (4.3) — the model's public history on this player. + Deferred-render: shows only when real ledger rows exist. */} + + {/* Session 55 — the self-learning loop's track record for this sport. "The system learns" — real hit rate on graded props, misses shown. */}
diff --git a/web/src/components/Nav.tsx b/web/src/components/Nav.tsx index 4b922e0..0e52384 100644 --- a/web/src/components/Nav.tsx +++ b/web/src/components/Nav.tsx @@ -24,6 +24,8 @@ const PRIMARY = [ ]; const MORE = [ { label: 'Explore', href: '/explore' }, + // Session 60 (4.4) — discoverable Parlay Lab entry (opens the drawer). + { label: 'Parlay Lab', href: '#parlay', action: 'parlay' as const }, { label: 'Compare', href: '/compare' }, { label: 'Tracker', href: '/tracker' }, { label: 'The Report', href: '/blog' }, @@ -153,7 +155,15 @@ export default function Nav() { key={l.href} href={l.href} role="menuitem" - onClick={() => setMoreOpen(false)} + onClick={(e) => { + if ('action' in l && l.action === 'parlay') { + e.preventDefault(); + if (typeof window !== 'undefined' && (window as Window & { __openParlay?: () => void }).__openParlay) { + (window as Window & { __openParlay?: () => void }).__openParlay!(); + } + } + setMoreOpen(false); + }} style={{ display: 'block', padding: '9px 10px', diff --git a/web/src/components/vyndr/ParlayPanel.tsx b/web/src/components/vyndr/ParlayPanel.tsx index 31831ba..d4ce8e8 100644 --- a/web/src/components/vyndr/ParlayPanel.tsx +++ b/web/src/components/vyndr/ParlayPanel.tsx @@ -5,6 +5,8 @@ import { useParlay } from '@/contexts/ParlayContext'; import { useAuth } from '@/contexts/AuthContext'; import GradeBadge from '@/components/vyndr/GradeBadge'; import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; +// 4.4 — the ONE shared stat-label formatter (lib/gradeAdapter, unit-tested). +import { statLabel } from '@/lib/gradeAdapter'; /** * ParlayPanel (Session 50) — the Parlay Lab. A bottom slide-up that shows the @@ -20,17 +22,25 @@ function tierMaxLegs(tier: string): number { } export default function ParlayPanel() { - const { legs, isOpen, toggle, close, removeLeg, clear, combined, correlation, payout, grading, maxLegs, setMaxLegs } = useParlay(); + const { legs, isOpen, toggle, close, open, removeLeg, clear, combined, correlation, payout, grading, maxLegs, setMaxLegs } = useParlay(); const { tier } = useAuth(); const fullLab = tier === 'desk' || tier === 'analyst'; // Sync the leg cap to the user's tier. useEffect(() => { setMaxLegs(tierMaxLegs(tier || 'free')); }, [tier, setMaxLegs]); - if (legs.length === 0) return null; + // Session 60 (night2/E, 4.4) — a discoverable entry point: the Nav's + // PARLAY LAB item opens the drawer even with zero legs. + useEffect(() => { + if (typeof window === 'undefined') return; + (window as Window & { __openParlay?: () => void }).__openParlay = open; + return () => { delete (window as Window & { __openParlay?: () => void }).__openParlay; }; + }, [open]); + + if (legs.length === 0 && !isOpen) return null; // Floating trigger when closed. - if (!isOpen) { + if (!isOpen && legs.length > 0) { return (
@@ -120,8 +131,17 @@ export default function ParlayPanel() { )} + ) : legs.length === 1 ? ( + // 4.4 — the 1-leg provisional read instead of a dead panel. +
+ Leg grade: + + — add a leg for the combined read. +
) : ( -
Add another leg to see the combined grade.
+
+ No legs yet — tap + on any graded prop to start building. +
)}