Session E (night2): Phase 4 — scan + parlay polish
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
<PriorReads player={selectedPlayer} stat={stat} />
|
||||
|
||||
{/* Session 55 — the self-learning loop's track record for this sport.
|
||||
"The system learns" — real hit rate on graded props, misses shown. */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
@@ -72,7 +82,8 @@ export default function ParlayPanel() {
|
||||
<div key={l.id} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '8px 10px', background: 'var(--bg-2)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
{l.archetype && <ArchetypeBadge archetype={l.archetype} size="sm" variant="full" />}
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#fff', whiteSpace: 'nowrap' }}>{l.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{l.stat} {l.direction === 'under' ? 'U' : 'O'}{l.line}</span>
|
||||
{/* 4.4 — humanized stat labels via the ONE shared formatter. */}
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{statLabel(l.stat)} {l.direction === 'under' ? 'U' : 'O'}{l.line}</span>
|
||||
<GradeBadge grade={l.grade} size="sm" />
|
||||
<button type="button" onClick={() => removeLeg(l.id)} aria-label="Remove leg" className="mono" style={{ marginLeft: 'auto', background: 'transparent', border: 'none', color: 'var(--miss)', cursor: 'pointer', fontSize: 14 }}>✕</button>
|
||||
</div>
|
||||
@@ -120,8 +131,17 @@ export default function ParlayPanel() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : legs.length === 1 ? (
|
||||
// 4.4 — the 1-leg provisional read instead of a dead panel.
|
||||
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 9, fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>
|
||||
<span>Leg grade:</span>
|
||||
<GradeBadge grade={legs[0].grade} size="sm" />
|
||||
<span>— add a leg for the combined read.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>Add another leg to see the combined grade.</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginBottom: 14 }}>
|
||||
No legs yet — tap <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>+</span> on any graded prop to start building.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={clear} className="mono"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* PriorReads (Session 60, night2/E — work-order 4.3, spec §7).
|
||||
* "PRIOR READS: [chips]" under a scan result — the model's own public
|
||||
* history on this player from ledger_entries. Deferred-render: nothing
|
||||
* shows until real rows exist; settled rows carry their outcome, pending
|
||||
* rows say pending. Never invented.
|
||||
*/
|
||||
|
||||
interface PriorRow {
|
||||
id: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
side: string;
|
||||
grade: string;
|
||||
game_date: string;
|
||||
outcome?: 'hit' | 'miss' | 'push' | null;
|
||||
actual_value?: number | null;
|
||||
}
|
||||
|
||||
export default function PriorReads({ player, stat }: { player: string; stat?: string }) {
|
||||
const [rows, setRows] = useState<PriorRow[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const params = new URLSearchParams({ player, limit: '8' });
|
||||
fetch(`/api/ledger/model?${params}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => { if (active && data) setRows(Array.isArray(data.entries) ? data.entries : []); })
|
||||
.catch(() => { /* self-hide */ });
|
||||
return () => { active = false; };
|
||||
}, [player]);
|
||||
|
||||
if (!rows || rows.length === 0) return null;
|
||||
// Same-stat reads first — the most relevant history for this scan.
|
||||
const sorted = stat
|
||||
? [...rows].sort((a, b) => Number(b.stat === stat.toLowerCase()) - Number(a.stat === stat.toLowerCase()))
|
||||
: rows;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 4px 0' }}>
|
||||
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-2)', marginBottom: 8 }}>
|
||||
PRIOR READS · {rows.length}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{sorted.slice(0, 6).map((r) => {
|
||||
const color = r.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
|
||||
: r.outcome === 'miss' ? 'var(--miss, #FF5252)'
|
||||
: r.outcome === 'push' ? 'var(--text-1)' : 'var(--text-2)';
|
||||
const mark = r.outcome === 'hit' ? '✓' : r.outcome === 'miss' ? '✕' : r.outcome === 'push' ? '–' : '…';
|
||||
return (
|
||||
<span
|
||||
key={r.id}
|
||||
className="mono"
|
||||
title={`${r.game_date} · graded ${r.grade}${r.actual_value != null ? ` · actual ${r.actual_value}` : ''}`}
|
||||
style={{ fontSize: 10.5, fontWeight: 700, padding: '3px 8px', borderRadius: 5, border: `1px solid ${color}`, color, letterSpacing: '0.03em' }}
|
||||
>
|
||||
{mark} {String(r.side).toUpperCase() === 'UNDER' ? 'U' : 'O'}{r.line} {r.stat.replace(/_/g, ' ')} · {r.grade}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,24 +36,33 @@ interface ProcessingGradeProps {
|
||||
* then the GradeResultCard reveals. Pure chrome — the underlying grade is fixed.
|
||||
*/
|
||||
export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToParlay, onReadAnother }: ProcessingGradeProps) {
|
||||
const [proc, setProc] = useState(true);
|
||||
// Session 60 (night2/E, spec §7) — full reveal choreography:
|
||||
// analyzing steps → DECLASSIFIED stamp → card with 90ms-staggered panels.
|
||||
// Reduced-motion users skip straight to the card (chrome, never data).
|
||||
const [phase, setPhase] = useState<'proc' | 'stamp' | 'card'>('proc');
|
||||
const [lit, setLit] = useState(0);
|
||||
|
||||
const factors = (data.signals || []).slice(0, 5);
|
||||
const total = factors.length || 1;
|
||||
|
||||
useEffect(() => {
|
||||
setProc(true);
|
||||
if (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setPhase('card');
|
||||
return;
|
||||
}
|
||||
setPhase('proc');
|
||||
setLit(0);
|
||||
const stepMs = 190;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
for (let i = 1; i <= total; i++) timers.push(setTimeout(() => setLit(i), stepMs * i));
|
||||
timers.push(setTimeout(() => setProc(false), stepMs * total + 360));
|
||||
timers.push(setTimeout(() => setPhase('stamp'), stepMs * total + 360));
|
||||
timers.push(setTimeout(() => setPhase('card'), stepMs * total + 360 + 700));
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [replayKey, total]);
|
||||
|
||||
if (!proc) {
|
||||
if (phase === 'card') {
|
||||
return (
|
||||
<div className="grade-reveal">
|
||||
<GradeResultCard
|
||||
data={data}
|
||||
replayKey={replayKey}
|
||||
@@ -61,6 +70,7 @@ export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToP
|
||||
onAddToParlay={onAddToParlay}
|
||||
onReadAnother={onReadAnother}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +81,17 @@ export default function ProcessingGrade({ data, replayKey = 0, onShare, onAddToP
|
||||
style={{ width: '100%', maxWidth: 640, margin: '0 auto', borderRadius: 12, overflow: 'hidden', border: '1px solid rgba(0,255,184,.25)', minHeight: 360, position: 'relative', boxShadow: '0 0 0 1px rgba(0,212,160,.18), 0 24px 70px -28px rgba(0,212,160,.35)' }}
|
||||
aria-label="Processing grade"
|
||||
>
|
||||
{/* §7 — the DECLASSIFIED stamp lands between analysis and the card. */}
|
||||
{phase === 'stamp' && (
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: 5, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(4,20,15,.55)' }}>
|
||||
<span
|
||||
className="mono stamp-in"
|
||||
style={{ fontSize: 30, fontWeight: 900, letterSpacing: '0.22em', color: 'var(--g-ap)', border: '3px solid var(--g-ap)', borderRadius: 6, padding: '10px 26px', transform: 'rotate(-7deg)', textShadow: '0 0 18px rgba(0,255,184,.7)', boxShadow: '0 0 30px rgba(0,255,184,.35), inset 0 0 22px rgba(0,255,184,.12)' }}
|
||||
>
|
||||
DECLASSIFIED
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="proc-scan" style={{ position: 'absolute', left: 0, right: 0, top: 0, height: '30%', background: 'linear-gradient(180deg, transparent, rgba(0,255,184,.12) 50%, transparent)', zIndex: 1 }} />
|
||||
<div style={{ position: 'relative', zIndex: 2, padding: '26px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 22 }}>
|
||||
|
||||
Reference in New Issue
Block a user