Session 59: Addendum + work-order 1.6 + Phase 2 + Phase 3 (2352 tests)
Overnight sprint for the Saturday 10 AM ET deploy gate — day one of the
public ledger record locks against freshly posted lines.
Task A — ledger team/opponent (migration 020, applied at 0 rows):
populated in both write paths from the real feed; opponent only when the
player's team matches a game participant (never guessed). Roadmap: Phase
4.5 WNBA ESPN-boxscore settlement (due ~Jul 24) + Phase 5 per-tier
calibration logged.
Task B — work-order 1.6 CLOSED (canonical player keys):
- searchPlayer resolves via nameKey; the old matcher deleted accents
("Sanchez" with acute -> "snchez") and substring-guessed onto the WRONG
player (the mismatched last-10 bug). Ambiguous -> null, never guess.
- Slate JOIN INVARIANT: a graded prop whose player's real team isn't in
the game is dropped (TB player can't render under MIL@PIT) — locked by
tests that fail the suite on regression.
- grades:{sport} TTL 2h -> 6h (expired between 5h cron gaps — the real
cause of /team "No active props" for slate players).
Task C — Phase 2 slate UX: tabs are THE filter (URL ?sport=, deep-linkable,
duplicate legacy tablist removed); cards cap at 6 graded props sorted
A+->F with ALL N READS in-place expander; waiting states show the real
next pipeline run ("Grades post ~6:00 PM ET").
Task D — Phase 3 mobile P0: root cause of vanished 390px nav was HIDE_ON
including '/' (landing had zero navigation) — fixed; html/body overflow-x
contained; GAME LINES collapses to best-line summary + "N BOOKS" expander
below 640px; venue drops before time/pitchers ever truncate.
Live verification: raw ESPN today STILL returns the Jun 13 NYK@SA Finals
game without a date pin; the pinned fetch returns 0 games, 0 off-date.
Backend 2327 -> 2352 tests (202 suites), web build exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -202,17 +202,21 @@ async function writeLedgerEntry(
|
||||
|
||||
// Cache-only snapshot read (never triggers an odds fetch → no quota).
|
||||
let lockedOdds: string | null = null;
|
||||
let team: string | null = null;
|
||||
try {
|
||||
const snap = await fetch(`${BACKEND_URL}/api/snapshot/${sport}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
}).then((r) => (r.ok ? r.json() : null));
|
||||
const match = (snap?.grades || []).find(
|
||||
(g: { player?: string; player_name?: string; stat_type?: string; stat?: string; gradedAt?: { line?: number; odds?: number | string | null } }) =>
|
||||
nameKey(g.player || g.player_name || '') === playerKey
|
||||
&& String(g.stat_type || g.stat || '').toLowerCase() === body.stat.toLowerCase()
|
||||
&& g.gradedAt && Number(g.gradedAt.line) === Number(body.line),
|
||||
interface SnapGrade { player?: string; player_name?: string; stat_type?: string; stat?: string; team?: string | null; gradedAt?: { line?: number; odds?: number | string | null } }
|
||||
const grades: SnapGrade[] = snap?.grades || [];
|
||||
// Team match needs only player identity; odds additionally need the SAME line.
|
||||
const samePlayer = grades.filter(
|
||||
(g) => nameKey(g.player || g.player_name || '') === playerKey
|
||||
&& String(g.stat_type || g.stat || '').toLowerCase() === body.stat.toLowerCase(),
|
||||
);
|
||||
team = samePlayer.find((g) => g.team)?.team ?? null;
|
||||
const match = samePlayer.find((g) => g.gradedAt && Number(g.gradedAt.line) === Number(body.line));
|
||||
if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds);
|
||||
} catch { /* absent beats wrong */ }
|
||||
|
||||
@@ -227,6 +231,10 @@ async function writeLedgerEntry(
|
||||
side: body.direction,
|
||||
locked_odds: lockedOdds,
|
||||
book: body.book ?? 'draftkings',
|
||||
// Session 59 — team from the snapshot's stats resolve (real feed);
|
||||
// opponent stays null on manual scans (no game context — never guessed).
|
||||
team,
|
||||
opponent: null,
|
||||
grade: data.grade,
|
||||
edge: typeof data.edge_pct === 'number' ? data.edge_pct : null,
|
||||
confidence: typeof data.confidence === 'number' ? data.confidence : null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
@@ -13,6 +13,8 @@ import Slate from '@/components/Slate';
|
||||
import { AccuracyBadge } from '@/components/vyndr';
|
||||
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
|
||||
import { emptyStateCopy } from '@/lib/emptyState';
|
||||
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
|
||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
@@ -69,8 +71,6 @@ interface RecentScan {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const SPORT_TABS: Sport[] = ['NBA', 'MLB', 'WNBA'];
|
||||
|
||||
const SPORT_COLOR: Record<Sport, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
@@ -180,12 +180,6 @@ export default function DashboardPage() {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const gameCountsBySport = useMemo(() => {
|
||||
// Updated whenever the sport's slate refreshes. We only know counts for
|
||||
// the current sport — others show a dash until clicked.
|
||||
return { [sport]: games?.length ?? 0 } as Partial<Record<Sport, number>>;
|
||||
}, [sport, games]);
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -234,42 +228,23 @@ export default function DashboardPage() {
|
||||
{/* Session 13 — Browse-first slate. Owns its own sport-tab UI,
|
||||
search, and inline grading. Renders ABOVE the existing
|
||||
intelligence sections (Top Graded / Most Parlayed / Recent
|
||||
Reads) which serve as supplementary surfaces. */}
|
||||
<Slate tier={tier} initialTab={primaryTab} preferredBooks={prefBooks} key={primaryTab} />
|
||||
|
||||
{/* Legacy sport tabs — supplementary, kept for the existing
|
||||
Top Graded / Most Parlayed flows below. */}
|
||||
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginTop: 40, marginBottom: 32, borderBottom: '1px solid var(--border)' }}>
|
||||
{SPORT_TABS.map((s) => {
|
||||
const active = s === sport;
|
||||
const count = gameCountsBySport[s];
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setSport(s)}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: `2px solid ${active ? SPORT_COLOR[s] : 'transparent'}`,
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{s}{' '}
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginLeft: 4 }}>
|
||||
{active && count != null ? `(${count})` : '·'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
Reads) which serve as supplementary surfaces.
|
||||
Session 59 (work-order 2.1) — the Slate's tabs are THE filter
|
||||
(URL-driven, ?sport=). The legacy duplicate tab row below is GONE;
|
||||
the supplementary sections follow the same selection via
|
||||
onTabChange. Don't re-add a second tablist. */}
|
||||
<Slate
|
||||
tier={tier}
|
||||
initialTab={primaryTab}
|
||||
preferredBooks={prefBooks}
|
||||
key={primaryTab}
|
||||
onTabChange={(t) => {
|
||||
if (t === 'nba') setSport('NBA');
|
||||
else if (t === 'mlb') setSport('MLB');
|
||||
else if (t === 'wnba') setSport('WNBA');
|
||||
// 'all' / 'soccer' → the legacy sections keep their last sport.
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top grades horizontal scroll */}
|
||||
<Section
|
||||
@@ -279,7 +254,8 @@ export default function DashboardPage() {
|
||||
{topGrades === null ? (
|
||||
<SkeletonRow />
|
||||
) : topGrades.length === 0 ? (
|
||||
<p style={emptyCopy}>No grades yet. The model is waiting on lines.</p>
|
||||
// Session 59 (work-order 2.3) — the real schedule, not passive waiting.
|
||||
<p style={emptyCopy}>No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.</p>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1270,3 +1270,26 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
|
||||
@media (max-width: 768px) {
|
||||
.terminal-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
|
||||
/* ── Session 59 (work-order 3.2) — overflow containment at 390px ──────── */
|
||||
|
||||
/* Horizontal scroll is CONTAINED to strips (tabs, chips, book tables) —
|
||||
the document itself never scrolls sideways. */
|
||||
@media (max-width: 767px) {
|
||||
html, body { overflow-x: hidden; }
|
||||
}
|
||||
|
||||
/* Card meta: venue drops FIRST on phones so time/pitchers never truncate. */
|
||||
@media (max-width: 639px) {
|
||||
.gc-venue { display: none !important; }
|
||||
}
|
||||
|
||||
/* GAME LINES: full 8-book table on desktop; on phones it collapses to the
|
||||
best-line summary row (.gl-summary) with an "N BOOKS ⌄" expander that
|
||||
reveals the full table (.gl-full.gl-expanded). */
|
||||
.gl-summary { display: none; }
|
||||
@media (max-width: 639px) {
|
||||
.gl-summary { display: flex !important; }
|
||||
.gl-full:not(.gl-expanded) { display: none !important; }
|
||||
.gl-full.gl-expanded { margin-top: 10px; }
|
||||
}
|
||||
|
||||
@@ -48,7 +48,12 @@ const MORE_ITEMS = [
|
||||
];
|
||||
|
||||
// Auth flows own the full screen — no app chrome.
|
||||
const HIDE_ON = new Set(['/login', '/signup', '/auth/callback', '/']);
|
||||
// Session 59 (work-order 3.1) — '/' REMOVED from this set. Hiding the bar on
|
||||
// the landing left anonymous phones with ZERO navigation (the desktop links
|
||||
// hide <768px and the hamburger was retired in S37) — the audit's vanished-
|
||||
// nav-at-390px finding. The tab bar is the only mobile nav; it shows
|
||||
// everywhere except true auth flows.
|
||||
const HIDE_ON = new Set(['/login', '/signup', '/auth/callback']);
|
||||
|
||||
function isActive(pathname: string, href?: string) {
|
||||
if (!href) return false;
|
||||
|
||||
@@ -187,7 +187,9 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
|
||||
time: formatGameTime(g.gameTime),
|
||||
venue: g.venue,
|
||||
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
|
||||
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex),
|
||||
// Session 59 (work-order 1.6) — pass the game's participants so the join
|
||||
// guard can drop bad feed rows (a player whose real team isn't in this game).
|
||||
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }),
|
||||
// Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
|
||||
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
|
||||
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
|
||||
@@ -359,12 +361,37 @@ export interface SlateProps {
|
||||
tier?: Tier;
|
||||
/** Session 49 — user's preferred books (highlighted in each card's lines). */
|
||||
preferredBooks?: string[];
|
||||
/** Session 59 (2.1) — the sport tabs are THE filter; parents (dashboard
|
||||
* legacy sections) subscribe instead of running their own tab row. */
|
||||
onTabChange?: (tab: SlateTab) => void;
|
||||
}
|
||||
|
||||
export default function Slate({ initialTab = 'all', tier = 'free', preferredBooks }: SlateProps) {
|
||||
const VALID_TABS = new Set<SlateTab>(['all', 'nba', 'wnba', 'mlb', 'soccer']);
|
||||
|
||||
/** ?sport= from the URL (deep-linkable tabs, spec §6 SportTabs). */
|
||||
function tabFromUrl(): SlateTab | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const q = new URLSearchParams(window.location.search).get('sport');
|
||||
const t = String(q || '').toLowerCase() as SlateTab;
|
||||
return VALID_TABS.has(t) ? t : null;
|
||||
}
|
||||
|
||||
export default function Slate({ initialTab = 'all', tier = 'free', preferredBooks, onTabChange }: SlateProps) {
|
||||
const router = useRouter();
|
||||
const { session } = useAuth();
|
||||
const [tab, setTab] = useState<SlateTab>(initialTab);
|
||||
// Session 59 (2.1) — the URL is the source of truth on load (?sport=mlb
|
||||
// deep-links a filtered slate); user prefs are the fallback default.
|
||||
const [tab, setTabState] = useState<SlateTab>(() => tabFromUrl() || initialTab);
|
||||
const setTab = (t: SlateTab) => {
|
||||
setTabState(t);
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = new URL(window.location.href);
|
||||
if (t === 'all') url.searchParams.delete('sport');
|
||||
else url.searchParams.set('sport', t);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
if (onTabChange) onTabChange(t);
|
||||
};
|
||||
// Session 23 — active stat category for the intelligence panels. 'all'
|
||||
// shows everything; selecting one narrows streaks + hot list. Schedule
|
||||
// and game lines stay visible regardless (handled inside GameCard).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
@@ -68,6 +69,34 @@ interface GameCardProps {
|
||||
preferredBooks?: string[];
|
||||
}
|
||||
|
||||
/* Session 59 (work-order 2.2) — cards were dumping entire rosters inline.
|
||||
Strips sort by their best grade (A+ → F) and the card shows at most
|
||||
MAX_VISIBLE_PROPS graded props; the rest sit behind "ALL N READS →",
|
||||
expanding in place. */
|
||||
const MAX_VISIBLE_PROPS = 6;
|
||||
const GRADE_RANK: Record<string, number> = {
|
||||
'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10,
|
||||
};
|
||||
const gradeRank = (g?: string | null) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99);
|
||||
const stripBestRank = (ps: PlayerStrip) => Math.min(99, ...ps.props.map((p) => gradeRank(p.grade)));
|
||||
const stripGradedCount = (ps: PlayerStrip) => ps.props.filter((p) => p.grade).length;
|
||||
|
||||
/** Sort strips best-grade-first and cut at the prop budget. Always shows at
|
||||
* least one strip; ungraded-only strips sort last. */
|
||||
function collapseStrips(strips: PlayerStrip[]) {
|
||||
const sorted = [...strips].sort((a, b) => stripBestRank(a) - stripBestRank(b));
|
||||
const totalReads = sorted.reduce((n, s) => n + stripGradedCount(s), 0);
|
||||
const visible: PlayerStrip[] = [];
|
||||
let budget = MAX_VISIBLE_PROPS;
|
||||
for (const s of sorted) {
|
||||
const cost = Math.max(1, stripGradedCount(s));
|
||||
if (visible.length > 0 && budget - cost < 0) break;
|
||||
visible.push(s);
|
||||
budget -= cost;
|
||||
}
|
||||
return { sorted, visible, totalReads, truncated: visible.length < sorted.length };
|
||||
}
|
||||
|
||||
/** Clickable team abbreviation → /team/:abbr (Session 51). Stops propagation so
|
||||
* it doesn't trigger the card's open-game handler; green underline on hover. */
|
||||
function TeamLink({ abbr, sport }: { abbr: string; sport: string }) {
|
||||
@@ -141,6 +170,15 @@ function PropRow({ prop: p, onAddParlay }: { prop: GameProp; onAddParlay?: (p: G
|
||||
export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks }: GameCardProps) {
|
||||
// Session 50 — Parlay Lab "+" wiring. Builds a leg from the strip + game.
|
||||
const { addLeg, removeLeg, legs, hasLeg } = useParlay();
|
||||
// Session 59 — 2.2 card collapse + 3.2 mobile book-table expander.
|
||||
const [showAllReads, setShowAllReads] = useState(false);
|
||||
const [linesExpanded, setLinesExpanded] = useState(false);
|
||||
const collapsed = useMemo(() => collapseStrips(g.playerStrips || []), [g.playerStrips]);
|
||||
const stripsToRender = showAllReads ? collapsed.sorted : collapsed.visible;
|
||||
const bestLine = (pick: (ln: GameLine) => boolean, val: (ln: GameLine) => string) => {
|
||||
const ln = (g.lines || []).find(pick);
|
||||
return ln ? val(ln) : (g.lines && g.lines[0] ? val(g.lines[0]) : '—');
|
||||
};
|
||||
const sportU = (g.sport || 'nba').toUpperCase();
|
||||
const gameId = `${g.away.abbr} @ ${g.home.abbr}`;
|
||||
const toLeg = (ps: PlayerStrip, p: StripProp) => ({
|
||||
@@ -184,11 +222,12 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SUB-HEADER */}
|
||||
{/* SUB-HEADER — Session 59 (3.2): venue drops FIRST on mobile
|
||||
(.gc-venue is display:none <640px) so time + pitchers never truncate. */}
|
||||
<div className="mono" style={{ padding: '0 16px 11px', fontSize: 11.5, color: 'var(--text-1)' }}>
|
||||
{g.away.name} <span style={{ color: 'var(--text-2)' }}>·</span> {g.home.name}
|
||||
<span style={{ color: 'var(--text-2)' }}> · </span>{g.time}
|
||||
{g.venue && (<><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</>)}
|
||||
<span style={{ color: 'var(--text-2)' }}> · </span><span style={{ whiteSpace: 'nowrap' }}>{g.time}</span>
|
||||
{g.venue && (<span className="gc-venue"><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</span>)}
|
||||
</div>
|
||||
|
||||
{/* MLB STARTING PITCHERS (Session 42) */}
|
||||
@@ -209,11 +248,29 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
{/* GAME LINES */}
|
||||
{/* GAME LINES — Session 59 (3.2): below 640px the 8-book table collapses
|
||||
to a best-line summary row with an "N BOOKS ⌄" expander; desktop
|
||||
always shows the full table. All values are real book numbers. */}
|
||||
{g.lines && g.lines.length > 0 && (
|
||||
<div style={{ padding: '13px 16px' }}>
|
||||
<SectionHead style={{ marginBottom: 11 }}>GAME LINES <span style={{ color: 'var(--text-2)' }}>· {g.lines.length} BOOKS</span></SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '78px 1fr 1fr 1fr', gap: '2px 4px', alignItems: 'center' }}>
|
||||
|
||||
{/* Mobile summary (hidden ≥640px via .gl-summary) */}
|
||||
<div className="gl-summary mono" style={{ alignItems: 'center', gap: 10, fontSize: 12 }}>
|
||||
<span><span className="label" style={{ fontSize: 9.5 }}>{g.away.abbr}</span> <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{bestLine((l) => !!l.bestAway, (l) => l.awayML)}</span></span>
|
||||
<span><span className="label" style={{ fontSize: 9.5 }}>{g.home.abbr}</span> <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{bestLine((l) => !!l.bestHome, (l) => l.homeML)}</span></span>
|
||||
<span><span className="label" style={{ fontSize: 9.5 }}>O/U</span> <span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{bestLine((l) => !!l.bestOU, (l) => l.ou)}</span></span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setLinesExpanded((v) => !v); }}
|
||||
className="mono"
|
||||
aria-expanded={linesExpanded}
|
||||
style={{ marginLeft: 'auto', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, color: 'var(--text-1)', fontSize: 10.5, fontWeight: 700, letterSpacing: '0.06em', padding: '5px 9px', cursor: 'pointer', minHeight: 30 }}
|
||||
>
|
||||
{g.lines.length} BOOKS {linesExpanded ? '⌃' : '⌄'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`gl-full ${linesExpanded ? 'gl-expanded' : ''} game-lines-grid`} style={{ display: 'grid', gridTemplateColumns: '78px 1fr 1fr 1fr', gap: '2px 4px', alignItems: 'center' }}>
|
||||
<div className="label" style={{ fontSize: 10 }}>BOOK</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{g.away.abbr} ML</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{g.home.abbr} ML</div>
|
||||
@@ -239,7 +296,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
<SectionHead style={{ marginBottom: 11 }}>GRADED PROPS</SectionHead>
|
||||
{g.playerStrips && g.playerStrips.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{g.playerStrips.map((ps, i) => (
|
||||
{stripsToRender.map((ps, i) => (
|
||||
<StatStrip
|
||||
key={i}
|
||||
player={ps.player}
|
||||
@@ -253,6 +310,25 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
{...stripHandlers(ps)}
|
||||
/>
|
||||
))}
|
||||
{/* Session 59 (2.2) — the rest of the roster expands in place. */}
|
||||
{collapsed.truncated && !showAllReads && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowAllReads(true); }}
|
||||
className="mono"
|
||||
style={{ alignSelf: 'flex-start', background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--g-a)', fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', padding: '6px 2px' }}
|
||||
>
|
||||
ALL {collapsed.totalReads} READS →
|
||||
</button>
|
||||
)}
|
||||
{collapsed.truncated && showAllReads && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowAllReads(false); }}
|
||||
className="mono"
|
||||
style={{ alignSelf: 'flex-start', background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-1)', fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', padding: '6px 2px' }}
|
||||
>
|
||||
COLLAPSE ↑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : g.props && g.props.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||
|
||||
export interface StatCell {
|
||||
label: string;
|
||||
@@ -216,10 +217,13 @@ export default function StatStrip({
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{props.map((p, i) => {
|
||||
if (p.awaiting) {
|
||||
// Session 59 (work-order 2.3) — the honest waiting state: the
|
||||
// real next pipeline run, not passive mystery.
|
||||
const next = nextRunLabelET();
|
||||
return (
|
||||
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: 'var(--text-1)' }}>{p.stat} {p.line}</span>
|
||||
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>Awaiting next scan</span>
|
||||
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{next ? `Grades post ${next}` : 'Awaiting next scan'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* ============================================================
|
||||
Session 59 (work-order 2.3) — the REAL pipeline schedule, for honest
|
||||
waiting states. "Awaiting next scan" told the user nothing; the truth is
|
||||
the cron fires at fixed UTC hours, so we can say "Grades post ~6:00 PM ET".
|
||||
|
||||
HOURS_UTC mirrors the backend default (snapshotScheduler SNAPSHOT_HOURS_UTC
|
||||
= 14,19,22,1,3). If the env schedule changes, update BOTH places — a wrong
|
||||
time here is a lie, which is worse than the old vague copy.
|
||||
|
||||
CommonJS so the plain-JS Jest suite can require it directly.
|
||||
============================================================ */
|
||||
|
||||
const HOURS_UTC = [14, 19, 22, 1, 3];
|
||||
|
||||
/** The next scheduled run strictly after `now`, as a Date. */
|
||||
function nextRunAt(now = new Date(), hours = HOURS_UTC) {
|
||||
const candidates = [];
|
||||
for (let dayOffset = 0; dayOffset <= 1; dayOffset += 1) {
|
||||
for (const h of hours) {
|
||||
const d = new Date(now.getTime());
|
||||
d.setUTCDate(d.getUTCDate() + dayOffset);
|
||||
d.setUTCHours(h, 0, 0, 0);
|
||||
if (d.getTime() > now.getTime()) candidates.push(d);
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => a.getTime() - b.getTime());
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
/** "~6:00 PM ET" for the next run. Empty string when the schedule is unknown. */
|
||||
function nextRunLabelET(now = new Date(), hours = HOURS_UTC) {
|
||||
const next = nextRunAt(now, hours);
|
||||
if (!next) return '';
|
||||
const t = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit',
|
||||
}).format(next);
|
||||
return `~${t} ET`;
|
||||
}
|
||||
|
||||
module.exports = { HOURS_UTC, nextRunAt, nextRunLabelET };
|
||||
@@ -241,14 +241,37 @@ function gradedAgo(iso, now = Date.now()) {
|
||||
return `${Math.round(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
/** Team-identity match by nickname token (last word) — "New York Yankees"
|
||||
* ↔ "Yankees"; exact string match also accepted. */
|
||||
function slateTeamsMatch(a, b) {
|
||||
if (!a || !b) return false;
|
||||
const sa = String(a).toLowerCase(), sb = String(b).toLowerCase();
|
||||
if (sa === sb) return true;
|
||||
return teamMascot(a) !== '' && teamMascot(a) === teamMascot(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build pre-graded `playerStrips` for one game by OVERLAYING the snapshot's
|
||||
* locked grades onto the game's odds-derived props (which already carry the
|
||||
* correct game grouping). Each prop is either graded (grade + gradedAt + delta)
|
||||
* or `awaiting:true` (no snapshot match yet → "Awaiting next scan", no Read
|
||||
* button). Archetype comes from the snapshot's per-player classification.
|
||||
*
|
||||
* Session 59 (work-order 1.6) — THE JOIN INVARIANT: when the snapshot knows
|
||||
* the player's REAL team (grades carry `team` from the stats resolve) and
|
||||
* the caller passes the game's participants (`gameTeams`), a prop whose
|
||||
* player does NOT belong to either team is DROPPED from the card entirely —
|
||||
* a bad feed row must not render a TB player under MIL@PIT. Props without
|
||||
* team info are kept (can't verify ≠ wrong).
|
||||
*/
|
||||
function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now()) {
|
||||
/**
|
||||
* @param {Array<object>} gameProps
|
||||
* @param {Record<string, any>} gradeIndex
|
||||
* @param {Record<string, any>} deltaIndex
|
||||
* @param {number} [now]
|
||||
* @param {{home?: string, away?: string} | null} [gameTeams]
|
||||
*/
|
||||
function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now(), gameTeams = null) {
|
||||
const byPlayer = {};
|
||||
const order = [];
|
||||
for (const p of gameProps || []) {
|
||||
@@ -257,10 +280,16 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
// / "AJ Ewing") merge into ONE strip; display the longest seen variant.
|
||||
const pk = nameKey(p.player);
|
||||
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)];
|
||||
// Join guard: known player team that isn't in this game → bad row, drop.
|
||||
const knownTeam = (rec && rec.team) || p.team || '';
|
||||
if (knownTeam && gameTeams && (gameTeams.home || gameTeams.away)) {
|
||||
const inGame = slateTeamsMatch(knownTeam, gameTeams.home) || slateTeamsMatch(knownTeam, gameTeams.away);
|
||||
if (!inGame) continue;
|
||||
}
|
||||
if (!byPlayer[pk]) {
|
||||
byPlayer[pk] = {
|
||||
player: displayName(p.player),
|
||||
team: p.team || '',
|
||||
team: knownTeam,
|
||||
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
||||
stats: [],
|
||||
props: [],
|
||||
|
||||
Reference in New Issue
Block a user