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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user