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
+48 -4
View File
@@ -4,10 +4,54 @@
2026-07-10 2026-07-10
## Current Phase ## Current Phase
SHIP BUILD v58.0 — Phase 1 "Truth Infrastructure": ledger_entries live in SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum,
Supabase (migration applied + RLS verified), both write paths wired, nightly work-order 1.6 closed (canonical player keys + slate join invariant),
settlement + CLV, /ledger MY READS | MODEL tabs, honest scan refusals. Phase 2 slate UX, Phase 3 mobile P0. DEPLOY GATE: Coolify by Sat 10 AM ET;
Next: work-order Phase 2 (slate UX) + Phase 3 (mobile), then Phase 2.5. first snapshot locks against freshly posted lines (manual internal trigger).
## Session 59 (2026-07-10 overnight) — SHIPPED ✅ ADDENDUM + 1.6 + PHASE 2 + PHASE 3
Backend 2327 → **2352 tests** (+25), 202 suites. Web build clean (exit 0).
Migration 020 (team/opponent) APPLIED while ledger_entries was at 0 rows —
zero backfill needed; VYNDR-on-team unblocked.
- **Task A** — `ledger_entries.team/opponent` populated in both write paths
(pipeline: stats-resolve team + prop's other side via nickname match,
NEVER guessed; scan: snapshot-grade team, opponent null). Roadmap: Phase
4.5 (WNBA ESPN box-score settlement — DUE ~Jul 24) + Phase 5 calibration-
by-grade-tier logged.
- **Task B (work-order 1.6 CLOSED)** — `mlbStatsAdapter.searchPlayer` now
resolves via canonical `nameKey` (the old matcher DELETED accents:
"Sánchez"→"snchez"≠"sanchez", then substring-guessed → wrong player's
last-10). Fallback = UNIQUE last-name+first-initial or null.
`buildPlayerStripsFromProps` gained the JOIN INVARIANT: a graded prop
whose player's real team isn't a game participant is DROPPED (TB player
can't render under MIL@PIT — suite fails if it regresses). Snapshot
grades now carry `team`. Root cause of /team "No active props":
grades:{sport} TTL (2h) < cron gap (5h) → now SNAP_TTL (6h).
- **Task C (Phase 2)** — 2.1 sport tabs are THE filter, URL-driven
(?sport=, deep-linkable); the dashboard's duplicate legacy tablist is
GONE (legacy sections subscribe via onTabChange). 2.2 cards cap at 6
graded props sorted A+→F with "ALL N READS →" expanding in place.
2.3 waiting states show the REAL next pipeline run ("Grades post
~6:00 PM ET" via lib/pipelineSchedule — mirrors SNAPSHOT_HOURS_UTC;
update BOTH if the cron changes). 2.4 = TTL fix + join fix (verify on
the live Yankees page post-deploy).
- **Task D (Phase 3)** — ROOT CAUSE of vanished mobile nav: HIDE_ON
included '/' — the landing had NO nav at 390px (desktop links hidden,
hamburger retired S37, tab bar suppressed). '/' removed. 3.2: html/body
overflow-x hidden <768; GAME LINES collapses to a best-line summary +
"N BOOKS ⌄" expander <640; venue (.gc-venue) drops first on phones.
- **Live verification:** raw ESPN (no date pin) STILL returns the Jun 13
NYK@SA Finals game today; the pinned fetch returns 0 games, 0 off-date.
### Morning deploy runbook (Sat, before ~10 AM ET)
1. Push already sent to gitea (Coolify betonblk) — confirm deploy finished.
2. `GET /api/internal/snapshot/status` (x-internal-key) → cron_armed true.
3. When the day's lines post: `POST /api/internal/snapshot/all`
(x-internal-key) → first public ledger rows lock against fresh lines.
4. Verify: `SELECT count(*), count(*) FILTER (WHERE team IS NOT NULL) FROM
ledger_entries WHERE user_id IS NULL;` + one sample row.
## Session 58 (2026-07-10) — SHIPPED ✅ PHASE 1: TRUTH INFRASTRUCTURE ## Session 58 (2026-07-10) — SHIPPED ✅ PHASE 1: TRUTH INFRASTRUCTURE
+26
View File
@@ -747,6 +747,32 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
18000) via /api/snapshot/summary `expected_interval_s`. Phase 2.5 drops 18000) via /api/snapshot/summary `expected_interval_s`. Phase 2.5 drops
the value to the intraday cadence — no UI change needed. the value to the intraday cadence — no UI change needed.
## Player Keys + Slate Join + Mobile Nav (Session 59 — non-obvious)
- **`mlbStatsAdapter.searchPlayer` resolves via `nameKey`** (canonical,
accent-folded). NEVER reintroduce substring matching — it returned the
WRONG player's id on a near-miss (the audit's mismatched last-10 bug).
Fallback is unique last-name+first-initial or null: a missing profile
beats another player's log.
- **The slate JOIN INVARIANT** lives in `buildPlayerStripsFromProps(…,
gameTeams)`: a graded prop whose player's real team (snapshot `team`,
from the stats resolve) isn't a game participant is DROPPED. Props with
no team info are kept (can't verify ≠ wrong). Tests in
slateAdapterStrips lock it.
- **grades:{sport} is written with SNAP_TTL (6h), NOT 2h** — the 2h TTL
expired between 5h-apart cron runs and blanked /team + Explore mid-day.
- **Sport tabs are THE filter** (?sport= URL param, history.replaceState).
The dashboard has NO second tablist — legacy sections subscribe via
Slate's `onTabChange`. Don't re-add a duplicate tab row.
- **`lib/pipelineSchedule.js` mirrors SNAPSHOT_HOURS_UTC** (14,19,22,1,3)
for "Grades post ~X ET" waiting states; a test cross-checks it against
snapshotScheduler.HOURS_UTC. Change the cron → change both.
- **BottomTabBar shows on '/'** — hiding it there left anon phones with
ZERO navigation (desktop links hide <768, hamburger retired). HIDE_ON is
auth flows only.
- **GameCard collapse**: 6 graded props max (sorted A+→F), "ALL N READS →"
expands in place; GAME LINES <640px = `.gl-summary` best-line row +
expander revealing `.gl-full.gl-expanded`.
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+17
View File
@@ -89,6 +89,23 @@ Authoritative scope from the Phase 1 GO prompt. **Build after Phase 3
the HeartbeatBar SYNC badge (normal <1.5x · amber ≥1.5x · STALE ≥3x) the HeartbeatBar SYNC badge (normal <1.5x · amber ≥1.5x · STALE ≥3x)
goes genuinely live with zero further UI changes. goes genuinely live with zero further UI changes.
## Phase 4.5 — WNBA settlement via ESPN box scores (DUE DATE, not "roadmap")
Logged Session 59. Pending-forever WNBA ledger rows are honest but become a
credibility hole past ~2 weeks of accumulation. **Due: within 2 weeks of
day one of the public record (by ~Jul 24 2026).** Scope: ESPN box-score
settle path in outcomeService + ledgerService `defaultGetPlayerStats`
(WNBA branch) so WNBA rows settle nightly like MLB. Build right after
Phase 4 (scan/parlay polish).
## Phase 5 addition — calibration by grade tier (logged Session 59)
Once settles mature: the MODEL tab shows A-tier hit% vs B-tier hit%
SEPARATELY (same n≥20 rule PER TIER — a tier below threshold shows
"building", never a %). The separation between tiers is the proof the
grades mean something. `ledger_entries.grade` already carries the tier;
this is an aggregate + UI change only.
--- ---
## Stat-type coverage target (fully built) ## Stat-type coverage target (fully built)
+28 -10
View File
@@ -129,22 +129,40 @@ async function getBatterVsPitcher(batterId, pitcherId, group = 'hitting') {
return splits.length > 0 ? (splits[0].stat || null) : null; return splits.length > 0 ? (splits[0].stat || null) : null;
} }
const normName = (n) => String(n == null ? '' : n).toLowerCase().replace(/[^a-z0-9]/g, ''); const { nameKey } = require('../../utils/playerName');
/** /**
* Resolve a player name → MLB person record (Session 43). Pulls the season * Resolve a player name → MLB person record (Session 43; rebuilt Session 59,
* player list (cached 24h — heavy but rarely changes) and matches by * work-order 1.6). Pulls the season player list (cached 24h) and matches on
* normalized full name. Returns { id, fullName, team, teamId, position } or * the CANONICAL nameKey — accent-folded, suffix/nickname-resolved — so
* null. Needed because every other adapter method keys on playerId. * "Sánchez" ≡ "Sanchez" and "Matt" ≡ "Matthew" resolve to ONE player.
*
* The old matcher lower-cased and stripped non-[a-z0-9], which DELETED
* accented letters ("Sánchez" → "snchez" ≠ "sanchez") and then fell back to
* a raw substring match that could silently return the WRONG player — the
* audit's "last-10 opponents don't match his team" bug. The fallback now
* requires a UNIQUE same-last-name + same-first-initial candidate; anything
* ambiguous returns null (a missing profile beats another player's log).
*/ */
async function searchPlayer(name, season = DEFAULT_SEASON) { async function searchPlayer(name, season = DEFAULT_SEASON) {
const target = normName(name); const targetKey = nameKey(name);
if (!target) return null; if (!targetKey) return null;
const url = `${BASE}/sports/1/players?season=${season}`; const url = `${BASE}/sports/1/players?season=${season}`;
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
const people = (data && Array.isArray(data.people)) ? data.people : []; const people = (data && Array.isArray(data.people)) ? data.people : [];
const hit = people.find((p) => normName(p.fullName) === target) let hit = people.find((p) => nameKey(p.fullName) === targetKey);
|| people.find((p) => normName(p.fullName).includes(target) && target.length >= 6); if (!hit) {
const parts = targetKey.split(' ');
const first = parts[0] || '';
const last = parts[parts.length - 1] || '';
if (first && last && first !== last) {
const cands = people.filter((p) => {
const k = nameKey(p.fullName).split(' ');
return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
});
if (cands.length === 1) hit = cands[0]; // unique or nothing — never guess
}
}
if (!hit) return null; if (!hit) return null;
return { return {
id: hit.id, id: hit.id,
@@ -234,5 +252,5 @@ module.exports = {
getTeams, getTeams,
resolveTeam, resolveTeam,
getTeamRoster, getTeamRoster,
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, normName }, __internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON },
}; };
+31
View File
@@ -73,6 +73,33 @@ const sideOf = (direction) =>
// fabricated zero line/odds is exactly what the data-semantics rule forbids). // fabricated zero line/odds is exactly what the data-semantics rule forbids).
const numOrNull = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v)); const numOrNull = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));
// Nickname token (last word, lowercased) — the stable cross-source team
// identifier ("New York Yankees" ↔ "Yankees" ↔ "NYY" won't match, but
// full-name feeds match full-name feeds; abbr feeds match abbr feeds).
const nickToken = (name) => {
const w = String(name || '').trim().split(/\s+/);
return (w[w.length - 1] || '').toLowerCase().replace(/[^a-z]/g, '');
};
const teamsMatch = (a, b) => {
if (!a || !b) return false;
const sa = String(a).toLowerCase(), sb = String(b).toLowerCase();
return sa === sb || nickToken(a) === nickToken(b);
};
/**
* Session 59 — team/opponent from the REAL feed. The player's team comes
* from the stats resolve (g.team); the opponent is the other side of the
* prop's game IF the team matches one of its participants. No match →
* opponent stays null — never guessed.
*/
function teamOpponentFor(g, prop) {
const team = g && g.team ? String(g.team) : null;
if (!team || !prop) return { team, opponent: null };
if (teamsMatch(team, prop.home_team)) return { team, opponent: prop.away_team || null };
if (teamsMatch(team, prop.away_team)) return { team, opponent: prop.home_team || null };
return { team, opponent: null };
}
/** Index odds props by nameKey|stat for lock/closing lookups. */ /** Index odds props by nameKey|stat for lock/closing lookups. */
function indexProps(props) { function indexProps(props) {
const map = {}; const map = {};
@@ -114,7 +141,10 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
const prop = byKey[`${nameKey(player)}|${stat}`] || null; const prop = byKey[`${nameKey(player)}|${stat}`] || null;
const gradedTs = locked.timestamp || nowIso; const gradedTs = locked.timestamp || nowIso;
const gameDate = dateET(prop && prop.game_time) || dateET(gradedTs) || todayET(); const gameDate = dateET(prop && prop.game_time) || dateET(gradedTs) || todayET();
const { team, opponent } = teamOpponentFor(g, prop);
rows.push({ rows.push({
team,
opponent,
user_id: null, user_id: null,
player_key: nameKey(player), player_key: nameKey(player),
player_name: normalizeName(player).display || player, player_name: normalizeName(player).display || player,
@@ -375,5 +405,6 @@ module.exports = {
__internals: { __internals: {
rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor, rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor,
dateET, sideOf, oddsForSide, isConfigured, CONFLICT, dateET, sideOf, oddsForSide, isConfigured, CONFLICT,
teamOpponentFor, teamsMatch,
}, },
}; };
+11 -2
View File
@@ -18,7 +18,6 @@
*/ */
const SNAP_TTL = 6 * 3600; // 6h — a snapshot is valid until the next run const SNAP_TTL = 6 * 3600; // 6h — a snapshot is valid until the next run
const GRADES_TTL = 2 * 3600; // matches gradeSlateService
const TICKER_TTL = 24 * 3600; const TICKER_TTL = 24 * 3600;
const TICKER_CAP = 50; const TICKER_CAP = 50;
const DELTA_NOISE = 0.5; // ignore movements smaller than this const DELTA_NOISE = 0.5; // ignore movements smaller than this
@@ -264,12 +263,17 @@ async function runSnapshot(sport, opts = {}) {
const oddsByKey = indexOdds(props); const oddsByKey = indexOdds(props);
const players = [...new Set(graded.map((g) => g.player || g.player_name).filter(Boolean))]; const players = [...new Set(graded.map((g) => g.player || g.player_name).filter(Boolean))];
const archByPlayer = {}; const archByPlayer = {};
// Session 59 — capture the player's REAL team from the same stats resolve
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
// and the slate join guard (a prop only attaches to its own game).
const teamByPlayer = {};
await mapLimit(players, STATS_CONCURRENCY, async (player) => { await mapLimit(players, STATS_CONCURRENCY, async (player) => {
try { try {
const stats = await deps.resolveStats(player, sp); const stats = await deps.resolveStats(player, sp);
if (stats && stats.found) { if (stats && stats.found) {
const c = deps.classify(sp, stats.classifierInput || {}); const c = deps.classify(sp, stats.classifierInput || {});
archByPlayer[player] = c.primary ? c.primary.name : null; archByPlayer[player] = c.primary ? c.primary.name : null;
if (stats.team) teamByPlayer[player] = stats.team;
} }
} catch { /* graceful — no badge */ } } catch { /* graceful — no badge */ }
}); });
@@ -278,6 +282,7 @@ async function runSnapshot(sport, opts = {}) {
...g, ...g,
gradedAt: gradedAtFor(g, oddsByKey, ts), gradedAt: gradedAtFor(g, oddsByKey, ts),
archetype: archByPlayer[g.player || g.player_name] || null, archetype: archByPlayer[g.player || g.player_name] || null,
team: teamByPlayer[g.player || g.player_name] || g.team || null,
})); }));
// Line deltas vs the previous snapshot's locked lines. // Line deltas vs the previous snapshot's locked lines.
@@ -288,7 +293,11 @@ async function runSnapshot(sport, opts = {}) {
if (prev) await deps.cacheSet(`snapshot:${sp}:previous`, prev, SNAP_TTL); if (prev) await deps.cacheSet(`snapshot:${sp}:previous`, prev, SNAP_TTL);
const snapshot = { sport: sp, updated_at: ts, grades: enriched, deltas, gradeCount: enriched.length }; const snapshot = { sport: sp, updated_at: ts, grades: enriched, deltas, gradeCount: enriched.length };
await deps.cacheSet(`snapshot:${sp}:latest`, snapshot, SNAP_TTL); await deps.cacheSet(`snapshot:${sp}:latest`, snapshot, SNAP_TTL);
await deps.cacheSet(`grades:${sp}`, { grades: enriched, updated_at: ts, source: (odds && odds.provider) || 'odds-api' }, GRADES_TTL); // Session 59 — grades:{sport} must outlive the gap between cron runs (up to
// 5h) or team rosters / Explore / leaders go dark mid-day. SNAP_TTL (6h),
// NOT the legacy 2h gradeSlateService TTL — that gap was why /team showed
// "No active props" for players who were on the slate (audit 2.4).
await deps.cacheSet(`grades:${sp}`, { grades: enriched, updated_at: ts, source: (odds && odds.provider) || 'odds-api' }, SNAP_TTL);
// Ticker exhaust. // Ticker exhaust.
const events = generateTickerEvents(sp, enriched, deltas, ts); const events = generateTickerEvents(sp, enriched, deltas, ts);
+23
View File
@@ -79,6 +79,29 @@ describe('rowsFromSnapshot — the write shape', () => {
expect(r.game_id).toBe('mlb:2026-07-10:BOS@NYY'); expect(r.game_id).toBe('mlb:2026-07-10:BOS@NYY');
}); });
// Session 59 — team/opponent addendum (migration 020).
test('captures team + opponent from the real feed (full-name MLB teams)', () => {
const grade = { ...GRADE, team: 'New York Yankees' };
const prop = { ...PROP, home_team: 'New York Yankees', away_team: 'Boston Red Sox' };
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
expect(r.team).toBe('New York Yankees');
expect(r.opponent).toBe('Boston Red Sox');
});
test('opponent is NEVER guessed: team not in the game → opponent null', () => {
const grade = { ...GRADE, team: 'Tampa Bay Rays' };
const prop = { ...PROP, home_team: 'Milwaukee Brewers', away_team: 'Pittsburgh Pirates' };
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
expect(r.team).toBe('Tampa Bay Rays');
expect(r.opponent).toBeNull();
});
test('no team from the stats resolve → both null (absent beats wrong)', () => {
const [r] = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
expect(r.team).toBeNull();
expect(r.opponent).toBeNull();
});
test('refused reads and grade-less entries never become rows', () => { test('refused reads and grade-less entries never become rows', () => {
const refused = { ...GRADE, grade: null, insufficient_data: true }; const refused = { ...GRADE, grade: null, insufficient_data: true };
const gradeless = { ...GRADE, grade: undefined }; const gradeless = { ...GRADE, grade: undefined };
+51
View File
@@ -125,3 +125,54 @@ describe('mlbStatsAdapter — batter vs pitcher', () => {
expect(mockAxiosGet).toHaveBeenCalledTimes(1); expect(mockAxiosGet).toHaveBeenCalledTimes(1);
}); });
}); });
// Session 59 (work-order 1.6) — canonical player resolution. The old matcher
// deleted accented letters ("Sánchez" → "snchez") and substring-guessed on a
// miss, which could return ANOTHER player's id (→ wrong last-10 log).
describe('searchPlayer — canonical nameKey resolution', () => {
const PLAYERS = {
data: {
people: [
{ id: 100, fullName: 'Cristopher Sanchez', currentTeam: { name: 'Philadelphia Phillies', id: 1 }, primaryPosition: { abbreviation: 'P' } },
{ id: 200, fullName: 'Brandon Lowe', currentTeam: { name: 'Tampa Bay Rays', id: 2 }, primaryPosition: { abbreviation: '2B' } },
{ id: 201, fullName: 'Nathaniel Lowe', currentTeam: { name: 'Texas Rangers', id: 3 }, primaryPosition: { abbreviation: '1B' } },
{ id: 202, fullName: 'Josh Lowe', currentTeam: { name: 'Tampa Bay Rays', id: 2 }, primaryPosition: { abbreviation: 'OF' } },
{ id: 300, fullName: 'Matthew Boyd', currentTeam: { name: 'Chicago Cubs', id: 4 }, primaryPosition: { abbreviation: 'P' } },
],
},
};
test('accented query resolves to the unaccented MLB record (Sánchez ≡ Sanchez)', async () => {
mockAxiosGet.mockResolvedValue(PLAYERS);
const hit = await adapter.searchPlayer('Cristopher Sánchez');
expect(hit).toBeTruthy();
expect(hit.id).toBe(100);
});
test('never returns another player on a shared last name (Brandon ≠ Nathaniel Lowe)', async () => {
mockAxiosGet.mockResolvedValue(PLAYERS);
const hit = await adapter.searchPlayer('Brandon Lowe');
expect(hit.id).toBe(200);
expect(hit.team).toBe('Tampa Bay Rays');
});
test('nickname resolves via nameKey (Matt Boyd → Matthew Boyd)', async () => {
mockAxiosGet.mockResolvedValue(PLAYERS);
const hit = await adapter.searchPlayer('Matt Boyd');
expect(hit && hit.id).toBe(300);
});
test('ambiguous fallback returns null — a missing profile beats a wrong one', async () => {
mockAxiosGet.mockResolvedValue({
data: {
people: [
{ id: 400, fullName: 'Jose Ramirez', currentTeam: { name: 'A' }, primaryPosition: { abbreviation: '3B' } },
{ id: 401, fullName: 'Jorge Ramirez', currentTeam: { name: 'B' }, primaryPosition: { abbreviation: 'P' } },
],
},
});
// "J. Ramirez" collapses to first-initial J — TWO candidates → null.
const hit = await adapter.searchPlayer('Jhon Ramirez');
expect(hit).toBeNull();
});
});
+28
View File
@@ -0,0 +1,28 @@
// Session 59 (work-order 2.3) — honest waiting states derive from the REAL
// cron schedule (UTC 14,19,22,1,3). A wrong time is a lie; keep HOURS_UTC in
// sync with snapshotScheduler's default.
const { HOURS_UTC, nextRunAt, nextRunLabelET } = require('../../web/src/lib/pipelineSchedule');
const { HOURS_UTC: BACKEND_HOURS } = require('../../src/snapshotScheduler');
describe('pipelineSchedule', () => {
test('mirrors the backend default cron hours EXACTLY', () => {
expect([...HOURS_UTC].sort((a, b) => a - b)).toEqual([...BACKEND_HOURS].sort((a, b) => a - b));
});
test('next run after 15:30 UTC is 19:00 UTC same day', () => {
const now = new Date('2026-07-10T15:30:00Z');
expect(nextRunAt(now).toISOString()).toBe('2026-07-10T19:00:00.000Z');
});
test('next run after 23:30 UTC rolls to 01:00 UTC next day', () => {
const now = new Date('2026-07-10T23:30:00Z');
expect(nextRunAt(now).toISOString()).toBe('2026-07-11T01:00:00.000Z');
});
test('label renders an ET clock time', () => {
// 19:00 UTC = 3:00 PM ET in July (EDT).
const label = nextRunLabelET(new Date('2026-07-10T15:30:00Z'));
expect(label).toBe('~3:00 PM ET');
});
});
+70
View File
@@ -95,3 +95,73 @@ describe('legacy GameCard uses BookChip (brand colors)', () => {
expect(src).toContain('book={r.book}'); expect(src).toContain('book={r.book}');
}); });
}); });
// Session 59 (work-order 1.6) — THE JOIN INVARIANT. A prop can only attach
// to a game where the player's team is one of the two participants. The
// audit found Brandon Lowe (TB) rendered under MIL@PIT — a bad feed row the
// overlay happily displayed.
describe('buildPlayerStripsFromProps — game-participant join guard', () => {
const GAME = { home: 'Milwaukee Brewers', away: 'Pittsburgh Pirates' };
it('drops a graded prop whose player team is not in the game (TB ∉ MIL@PIT)', () => {
const props = [{ player: 'Brandon Lowe', stat_type: 'hits', line: 1.5 }];
const gradeIndex = adapter.indexGrades([
{ player: 'Brandon Lowe', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A',
team: 'Tampa Bay Rays', gradedAt: { line: 1.5, timestamp: '2026-07-10T02:00:00Z' } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME);
expect(strips).toEqual([]);
});
it('keeps a prop whose player team IS a participant + stamps the team', () => {
const props = [{ player: 'Willy Adames', stat_type: 'hits', line: 1.5 }];
const gradeIndex = adapter.indexGrades([
{ player: 'Willy Adames', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B',
team: 'Milwaukee Brewers', gradedAt: { line: 1.5, timestamp: '2026-07-10T02:00:00Z' } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME);
expect(strips).toHaveLength(1);
expect(strips[0].team).toBe('Milwaukee Brewers');
});
it('keeps props with no team info (cannot verify ≠ wrong)', () => {
const props = [{ player: 'Mystery Guy', stat_type: 'hits', line: 0.5 }];
const strips = adapter.buildPlayerStripsFromProps(props, {}, {}, Date.now(), GAME);
expect(strips).toHaveLength(1);
expect(strips[0].props[0].awaiting).toBe(true);
});
// The invariant itself, as the suite-failing rule the work order asked for:
it('INVARIANT: every rendered strip with a known team belongs to the game', () => {
const props = [
{ player: 'Willy Adames', stat_type: 'hits', line: 1.5 },
{ player: 'Brandon Lowe', stat_type: 'hits', line: 1.5 },
{ player: 'Unknown Player', stat_type: 'runs', line: 0.5 },
];
const gradeIndex = adapter.indexGrades([
{ player: 'Willy Adames', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', team: 'Milwaukee Brewers', gradedAt: { line: 1.5, timestamp: 'x' } },
{ player: 'Brandon Lowe', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', team: 'Tampa Bay Rays', gradedAt: { line: 1.5, timestamp: 'x' } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME);
const participants = ['milwaukee brewers', 'pittsburgh pirates'];
for (const s of strips) {
if (!s.team) continue;
expect(participants).toContain(String(s.team).toLowerCase());
}
expect(strips.find((s) => String(s.team).toLowerCase() === 'tampa bay rays')).toBeUndefined();
});
});
// Sánchez/Sanchez single-key regression (work-order 1.6 #4).
describe('diacritic variants resolve to ONE key across the slate', () => {
it('an accented prop matches an unaccented snapshot grade (one strip, graded)', () => {
const props = [{ player: 'Cristopher Sánchez', stat_type: 'strikeouts', line: 5.5 }];
const gradeIndex = adapter.indexGrades([
{ player: 'Cristopher Sanchez', stat_type: 'strikeouts', line: 5.5, direction: 'over', grade: 'A',
gradedAt: { line: 5.5, timestamp: 'x' } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips).toHaveLength(1);
expect(strips[0].props[0].grade).toBe('A');
});
});
+63
View File
@@ -110,3 +110,66 @@ describe('Phase F — PWA manifest + viewport (§11)', () => {
expect(read('app/layout.tsx')).toContain("viewportFit: 'cover'"); expect(read('app/layout.tsx')).toContain("viewportFit: 'cover'");
}); });
}); });
// ── Session 59 — work-order Phase 3 (mobile P0) + Phase 2 slate UX ──────────
describe('Phase 3.1 — the tab bar is the mobile nav EVERYWHERE except auth', () => {
const src = read('components/BottomTabBar.tsx');
it('does NOT hide on the landing (anon phones had zero navigation)', () => {
const hideOn = src.match(/const HIDE_ON = new Set\(\[([^\]]*)\]\)/);
expect(hideOn).toBeTruthy();
expect(hideOn[1]).not.toContain("'/'");
['/login', '/signup', '/auth/callback'].forEach((p) => expect(hideOn[1]).toContain(p));
});
});
describe('Phase 3.2 — overflow containment at 390px', () => {
const css = read('app/globals.css');
it('document never scrolls horizontally on phones', () => {
expect(css).toMatch(/@media \(max-width: 767px\)[\s\S]*?html, body \{ overflow-x: hidden/);
});
it('venue drops first on phones (time/pitchers never truncate)', () => {
expect(css).toContain('.gc-venue { display: none');
expect(read('components/vyndr/GameCard.tsx')).toContain('className="gc-venue"');
});
it('book table collapses to a best-line summary with an expander below md', () => {
expect(css).toContain('.gl-summary { display: none; }');
expect(css).toContain('.gl-full:not(.gl-expanded) { display: none');
const card = read('components/vyndr/GameCard.tsx');
expect(card).toContain('gl-summary');
expect(card).toContain('BOOKS {linesExpanded');
});
});
describe('Phase 2.2 — card collapse (6-prop cap, ALL N READS)', () => {
const card = read('components/vyndr/GameCard.tsx');
it('caps visible graded props and sorts strips best-grade-first', () => {
expect(card).toContain('MAX_VISIBLE_PROPS = 6');
expect(card).toMatch(/sort\(\(a, b\) => stripBestRank\(a\) - stripBestRank\(b\)\)/);
});
it('offers the ALL N READS expander in place', () => {
expect(card).toContain('ALL {collapsed.totalReads} READS →');
expect(card).toContain('COLLAPSE ↑');
});
});
describe('Phase 2.1 — sport tabs are THE filter, URL-driven', () => {
const slate = read('components/Slate.tsx');
it('reads and writes ?sport= on the URL', () => {
expect(slate).toContain(".get('sport')"); // tabFromUrl reads the deep link
expect(slate).toContain("url.searchParams.set('sport', t)");
expect(slate).toContain('history.replaceState');
});
it('the dashboard has NO second tablist — it subscribes via onTabChange', () => {
const dash = read('app/dashboard/page.tsx');
expect(dash).toContain('onTabChange');
expect(dash).not.toMatch(/role="tablist"\s+aria-label="Sport"/);
});
});
describe('Phase 2.3 — honest waiting states', () => {
it('awaiting props show the real next pipeline run, not passive mystery', () => {
expect(read('components/vyndr/StatStrip.tsx')).toContain('nextRunLabelET');
expect(read('app/dashboard/page.tsx')).toContain('nextRunLabelET');
});
});
+13 -5
View File
@@ -202,17 +202,21 @@ async function writeLedgerEntry(
// Cache-only snapshot read (never triggers an odds fetch → no quota). // Cache-only snapshot read (never triggers an odds fetch → no quota).
let lockedOdds: string | null = null; let lockedOdds: string | null = null;
let team: string | null = null;
try { try {
const snap = await fetch(`${BACKEND_URL}/api/snapshot/${sport}`, { const snap = await fetch(`${BACKEND_URL}/api/snapshot/${sport}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
cache: 'no-store', cache: 'no-store',
}).then((r) => (r.ok ? r.json() : null)); }).then((r) => (r.ok ? r.json() : null));
const match = (snap?.grades || []).find( interface SnapGrade { player?: string; player_name?: string; stat_type?: string; stat?: string; team?: string | null; gradedAt?: { line?: number; odds?: number | string | null } }
(g: { player?: string; player_name?: string; stat_type?: string; stat?: string; gradedAt?: { line?: number; odds?: number | string | null } }) => const grades: SnapGrade[] = snap?.grades || [];
nameKey(g.player || g.player_name || '') === playerKey // Team match needs only player identity; odds additionally need the SAME line.
&& String(g.stat_type || g.stat || '').toLowerCase() === body.stat.toLowerCase() const samePlayer = grades.filter(
&& g.gradedAt && Number(g.gradedAt.line) === Number(body.line), (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); if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds);
} catch { /* absent beats wrong */ } } catch { /* absent beats wrong */ }
@@ -227,6 +231,10 @@ async function writeLedgerEntry(
side: body.direction, side: body.direction,
locked_odds: lockedOdds, locked_odds: lockedOdds,
book: body.book ?? 'draftkings', 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, grade: data.grade,
edge: typeof data.edge_pct === 'number' ? data.edge_pct : null, edge: typeof data.edge_pct === 'number' ? data.edge_pct : null,
confidence: typeof data.confidence === 'number' ? data.confidence : null, confidence: typeof data.confidence === 'number' ? data.confidence : null,
+22 -46
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useParlay } from '@/contexts/ParlayContext'; import { useParlay } from '@/contexts/ParlayContext';
@@ -13,6 +13,8 @@ import Slate from '@/components/Slate';
import { AccuracyBadge } from '@/components/vyndr'; import { AccuracyBadge } from '@/components/vyndr';
// Session 57 (Phase 0) — honest per-sport empty-slate copy. // Session 57 (Phase 0) — honest per-sport empty-slate copy.
import { emptyStateCopy } from '@/lib/emptyState'; 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'; type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -69,8 +71,6 @@ interface RecentScan {
created_at: string; created_at: string;
} }
const SPORT_TABS: Sport[] = ['NBA', 'MLB', 'WNBA'];
const SPORT_COLOR: Record<Sport, string> = { const SPORT_COLOR: Record<Sport, string> = {
NBA: '#E94B3C', NBA: '#E94B3C',
MLB: '#1E90FF', MLB: '#1E90FF',
@@ -180,12 +180,6 @@ export default function DashboardPage() {
} }
}, [user]); }, [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) { if (authLoading || !user) {
return ( return (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <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, {/* Session 13 — Browse-first slate. Owns its own sport-tab UI,
search, and inline grading. Renders ABOVE the existing search, and inline grading. Renders ABOVE the existing
intelligence sections (Top Graded / Most Parlayed / Recent intelligence sections (Top Graded / Most Parlayed / Recent
Reads) which serve as supplementary surfaces. */} Reads) which serve as supplementary surfaces.
<Slate tier={tier} initialTab={primaryTab} preferredBooks={prefBooks} key={primaryTab} /> Session 59 (work-order 2.1) — the Slate's tabs are THE filter
(URL-driven, ?sport=). The legacy duplicate tab row below is GONE;
{/* Legacy sport tabs — supplementary, kept for the existing the supplementary sections follow the same selection via
Top Graded / Most Parlayed flows below. */} onTabChange. Don't re-add a second tablist. */}
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginTop: 40, marginBottom: 32, borderBottom: '1px solid var(--border)' }}> <Slate
{SPORT_TABS.map((s) => { tier={tier}
const active = s === sport; initialTab={primaryTab}
const count = gameCountsBySport[s]; preferredBooks={prefBooks}
return ( key={primaryTab}
<button onTabChange={(t) => {
key={s} if (t === 'nba') setSport('NBA');
role="tab" else if (t === 'mlb') setSport('MLB');
aria-selected={active} else if (t === 'wnba') setSport('WNBA');
onClick={() => setSport(s)} // 'all' / 'soccer' → the legacy sections keep their last sport.
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>
{/* Top grades horizontal scroll */} {/* Top grades horizontal scroll */}
<Section <Section
@@ -279,7 +254,8 @@ export default function DashboardPage() {
{topGrades === null ? ( {topGrades === null ? (
<SkeletonRow /> <SkeletonRow />
) : topGrades.length === 0 ? ( ) : 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 <div
style={{ style={{
+23
View File
@@ -1270,3 +1270,26 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
@media (max-width: 768px) { @media (max-width: 768px) {
.terminal-grid { grid-template-columns: 1fr !important; } .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; }
}
+6 -1
View File
@@ -48,7 +48,12 @@ const MORE_ITEMS = [
]; ];
// Auth flows own the full screen — no app chrome. // 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) { function isActive(pathname: string, href?: string) {
if (!href) return false; if (!href) return false;
+30 -3
View File
@@ -187,7 +187,9 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
time: formatGameTime(g.gameTime), time: formatGameTime(g.gameTime),
venue: g.venue, venue: g.venue,
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [], 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). // Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined, pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })), streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
@@ -359,12 +361,37 @@ export interface SlateProps {
tier?: Tier; tier?: Tier;
/** Session 49 — user's preferred books (highlighted in each card's lines). */ /** Session 49 — user's preferred books (highlighted in each card's lines). */
preferredBooks?: string[]; 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 router = useRouter();
const { session } = useAuth(); 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' // Session 23 — active stat category for the intelligence panels. 'all'
// shows everything; selecting one narrows streaks + hot list. Schedule // shows everything; selecting one narrows streaks + hot list. Schedule
// and game lines stay visible regardless (handled inside GameCard). // and game lines stay visible regardless (handled inside GameCard).
+82 -6
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react';
import SportBadge from '@/components/vyndr/SportBadge'; import SportBadge from '@/components/vyndr/SportBadge';
import SectionHead from '@/components/vyndr/SectionHead'; import SectionHead from '@/components/vyndr/SectionHead';
import GradeBadge from '@/components/vyndr/GradeBadge'; import GradeBadge from '@/components/vyndr/GradeBadge';
@@ -68,6 +69,34 @@ interface GameCardProps {
preferredBooks?: string[]; 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 /** Clickable team abbreviation → /team/:abbr (Session 51). Stops propagation so
* it doesn't trigger the card's open-game handler; green underline on hover. */ * it doesn't trigger the card's open-game handler; green underline on hover. */
function TeamLink({ abbr, sport }: { abbr: string; sport: string }) { 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) { export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks }: GameCardProps) {
// Session 50 — Parlay Lab "+" wiring. Builds a leg from the strip + game. // Session 50 — Parlay Lab "+" wiring. Builds a leg from the strip + game.
const { addLeg, removeLeg, legs, hasLeg } = useParlay(); 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 sportU = (g.sport || 'nba').toUpperCase();
const gameId = `${g.away.abbr} @ ${g.home.abbr}`; const gameId = `${g.away.abbr} @ ${g.home.abbr}`;
const toLeg = (ps: PlayerStrip, p: StripProp) => ({ const toLeg = (ps: PlayerStrip, p: StripProp) => ({
@@ -184,11 +222,12 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
)} )}
</div> </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)' }}> <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} {g.away.name} <span style={{ color: 'var(--text-2)' }}>·</span> {g.home.name}
<span style={{ color: 'var(--text-2)' }}> · </span>{g.time} <span style={{ color: 'var(--text-2)' }}> · </span><span style={{ whiteSpace: 'nowrap' }}>{g.time}</span>
{g.venue && (<><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</>)} {g.venue && (<span className="gc-venue"><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</span>)}
</div> </div>
{/* MLB STARTING PITCHERS (Session 42) */} {/* 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)' }} /> <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 && ( {g.lines && g.lines.length > 0 && (
<div style={{ padding: '13px 16px' }}> <div style={{ padding: '13px 16px' }}>
<SectionHead style={{ marginBottom: 11 }}>GAME LINES <span style={{ color: 'var(--text-2)' }}>· {g.lines.length} BOOKS</span></SectionHead> <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 }}>BOOK</div>
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{g.away.abbr} ML</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> <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> <SectionHead style={{ marginBottom: 11 }}>GRADED PROPS</SectionHead>
{g.playerStrips && g.playerStrips.length > 0 ? ( {g.playerStrips && g.playerStrips.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.playerStrips.map((ps, i) => ( {stripsToRender.map((ps, i) => (
<StatStrip <StatStrip
key={i} key={i}
player={ps.player} player={ps.player}
@@ -253,6 +310,25 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
{...stripHandlers(ps)} {...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> </div>
) : g.props && g.props.length > 0 ? ( ) : g.props && g.props.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
+5 -1
View File
@@ -1,5 +1,6 @@
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import GradeBadge from '@/components/vyndr/GradeBadge'; import GradeBadge from '@/components/vyndr/GradeBadge';
import { nextRunLabelET } from '@/lib/pipelineSchedule';
export interface StatCell { export interface StatCell {
label: string; label: string;
@@ -216,10 +217,13 @@ export default function StatStrip({
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{props.map((p, i) => { {props.map((p, i) => {
if (p.awaiting) { 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 ( return (
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}> <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-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> </div>
); );
} }
+40
View File
@@ -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 };
+31 -2
View File
@@ -241,14 +241,37 @@ function gradedAgo(iso, now = Date.now()) {
return `${Math.round(hrs / 24)}d ago`; 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 * 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 * locked grades onto the game's odds-derived props (which already carry the
* correct game grouping). Each prop is either graded (grade + gradedAt + delta) * correct game grouping). Each prop is either graded (grade + gradedAt + delta)
* or `awaiting:true` (no snapshot match yet → "Awaiting next scan", no Read * or `awaiting:true` (no snapshot match yet → "Awaiting next scan", no Read
* button). Archetype comes from the snapshot's per-player classification. * 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 byPlayer = {};
const order = []; const order = [];
for (const p of gameProps || []) { 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. // / "AJ Ewing") merge into ONE strip; display the longest seen variant.
const pk = nameKey(p.player); const pk = nameKey(p.player);
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)]; 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]) { if (!byPlayer[pk]) {
byPlayer[pk] = { byPlayer[pk] = {
player: displayName(p.player), player: displayName(p.player),
team: p.team || '', team: knownTeam,
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined, archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
stats: [], stats: [],
props: [], props: [],