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:
Kev
2026-07-10 22:24:47 -04:00
parent c96e74c54b
commit d10bb4cce2
20 changed files with 648 additions and 80 deletions
+6 -1
View File
@@ -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;
+30 -3
View File
@@ -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).
+82 -6
View File
@@ -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 }}>
+5 -1
View File
@@ -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>
);
}