From d10bb4cce28cbf331ba2f82baa215c1b74a8b4de Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 10 Jul 2026 22:24:47 -0400 Subject: [PATCH] Session 59: Addendum + work-order 1.6 + Phase 2 + Phase 3 (2352 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BUILD-STATE.md | 52 ++++++++++++-- CLAUDE.md | 26 +++++++ specs/vyndr-roadmap.md | 17 +++++ src/services/adapters/mlbStatsAdapter.js | 38 +++++++--- src/services/ledgerService.js | 31 +++++++++ src/services/snapshotService.js | 13 +++- tests/unit/ledgerService.test.js | 23 +++++++ tests/unit/mlbStatsAdapter.test.js | 51 ++++++++++++++ tests/unit/pipelineSchedule.test.js | 28 ++++++++ tests/unit/slateAdapterStrips.test.js | 70 +++++++++++++++++++ tests/unit/vyndrMobile.test.js | 63 +++++++++++++++++ web/src/app/api/scan/route.ts | 18 +++-- web/src/app/dashboard/page.tsx | 68 ++++++------------ web/src/app/globals.css | 23 +++++++ web/src/components/BottomTabBar.tsx | 7 +- web/src/components/Slate.tsx | 33 ++++++++- web/src/components/vyndr/GameCard.tsx | 88 ++++++++++++++++++++++-- web/src/components/vyndr/StatStrip.tsx | 6 +- web/src/lib/pipelineSchedule.js | 40 +++++++++++ web/src/lib/slateAdapter.js | 33 ++++++++- 20 files changed, 648 insertions(+), 80 deletions(-) create mode 100644 tests/unit/pipelineSchedule.test.js create mode 100644 web/src/lib/pipelineSchedule.js diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 8a55a9e..0acd3fa 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,10 +4,54 @@ 2026-07-10 ## Current Phase -SHIP BUILD v58.0 — Phase 1 "Truth Infrastructure": ledger_entries live in -Supabase (migration applied + RLS verified), both write paths wired, nightly -settlement + CLV, /ledger MY READS | MODEL tabs, honest scan refusals. -Next: work-order Phase 2 (slate UX) + Phase 3 (mobile), then Phase 2.5. +SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum, +work-order 1.6 closed (canonical player keys + slate join invariant), +Phase 2 slate UX, Phase 3 mobile P0. DEPLOY GATE: Coolify by Sat 10 AM ET; +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 diff --git a/CLAUDE.md b/CLAUDE.md index 5c22018..16ea2b6 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/specs/vyndr-roadmap.md b/specs/vyndr-roadmap.md index beeb37b..b399434 100644 --- a/specs/vyndr-roadmap.md +++ b/specs/vyndr-roadmap.md @@ -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) 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) diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index f813562..2bc2d44 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -129,22 +129,40 @@ async function getBatterVsPitcher(batterId, pitcherId, group = 'hitting') { 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 - * player list (cached 24h — heavy but rarely changes) and matches by - * normalized full name. Returns { id, fullName, team, teamId, position } or - * null. Needed because every other adapter method keys on playerId. + * Resolve a player name → MLB person record (Session 43; rebuilt Session 59, + * work-order 1.6). Pulls the season player list (cached 24h) and matches on + * the CANONICAL nameKey — accent-folded, suffix/nickname-resolved — so + * "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) { - const target = normName(name); - if (!target) return null; + const targetKey = nameKey(name); + if (!targetKey) return null; const url = `${BASE}/sports/1/players?season=${season}`; const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); const people = (data && Array.isArray(data.people)) ? data.people : []; - const hit = people.find((p) => normName(p.fullName) === target) - || people.find((p) => normName(p.fullName).includes(target) && target.length >= 6); + let hit = people.find((p) => nameKey(p.fullName) === targetKey); + 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; return { id: hit.id, @@ -234,5 +252,5 @@ module.exports = { getTeams, resolveTeam, getTeamRoster, - __internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, normName }, + __internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON }, }; diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 630a4b3..68c4208 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -73,6 +73,33 @@ const sideOf = (direction) => // fabricated zero line/odds is exactly what the data-semantics rule forbids). 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. */ function indexProps(props) { const map = {}; @@ -114,7 +141,10 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { const prop = byKey[`${nameKey(player)}|${stat}`] || null; const gradedTs = locked.timestamp || nowIso; const gameDate = dateET(prop && prop.game_time) || dateET(gradedTs) || todayET(); + const { team, opponent } = teamOpponentFor(g, prop); rows.push({ + team, + opponent, user_id: null, player_key: nameKey(player), player_name: normalizeName(player).display || player, @@ -375,5 +405,6 @@ module.exports = { __internals: { rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor, dateET, sideOf, oddsForSide, isConfigured, CONFLICT, + teamOpponentFor, teamsMatch, }, }; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 9b515bd..67b4f91 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -18,7 +18,6 @@ */ 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_CAP = 50; const DELTA_NOISE = 0.5; // ignore movements smaller than this @@ -264,12 +263,17 @@ async function runSnapshot(sport, opts = {}) { const oddsByKey = indexOdds(props); const players = [...new Set(graded.map((g) => g.player || g.player_name).filter(Boolean))]; 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) => { try { const stats = await deps.resolveStats(player, sp); if (stats && stats.found) { const c = deps.classify(sp, stats.classifierInput || {}); archByPlayer[player] = c.primary ? c.primary.name : null; + if (stats.team) teamByPlayer[player] = stats.team; } } catch { /* graceful — no badge */ } }); @@ -278,6 +282,7 @@ async function runSnapshot(sport, opts = {}) { ...g, gradedAt: gradedAtFor(g, oddsByKey, ts), 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. @@ -288,7 +293,11 @@ async function runSnapshot(sport, opts = {}) { if (prev) await deps.cacheSet(`snapshot:${sp}:previous`, prev, SNAP_TTL); 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(`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. const events = generateTickerEvents(sp, enriched, deltas, ts); diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index 4201aa3..47e7a59 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -79,6 +79,29 @@ describe('rowsFromSnapshot — the write shape', () => { 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', () => { const refused = { ...GRADE, grade: null, insufficient_data: true }; const gradeless = { ...GRADE, grade: undefined }; diff --git a/tests/unit/mlbStatsAdapter.test.js b/tests/unit/mlbStatsAdapter.test.js index b5e20ec..00d32c6 100644 --- a/tests/unit/mlbStatsAdapter.test.js +++ b/tests/unit/mlbStatsAdapter.test.js @@ -125,3 +125,54 @@ describe('mlbStatsAdapter — batter vs pitcher', () => { 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(); + }); +}); diff --git a/tests/unit/pipelineSchedule.test.js b/tests/unit/pipelineSchedule.test.js new file mode 100644 index 0000000..18b174a --- /dev/null +++ b/tests/unit/pipelineSchedule.test.js @@ -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'); + }); +}); diff --git a/tests/unit/slateAdapterStrips.test.js b/tests/unit/slateAdapterStrips.test.js index b03130e..da46fa8 100644 --- a/tests/unit/slateAdapterStrips.test.js +++ b/tests/unit/slateAdapterStrips.test.js @@ -95,3 +95,73 @@ describe('legacy GameCard uses BookChip (brand colors)', () => { 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'); + }); +}); diff --git a/tests/unit/vyndrMobile.test.js b/tests/unit/vyndrMobile.test.js index b34e377..5b9027a 100644 --- a/tests/unit/vyndrMobile.test.js +++ b/tests/unit/vyndrMobile.test.js @@ -110,3 +110,66 @@ describe('Phase F — PWA manifest + viewport (§11)', () => { 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'); + }); +}); diff --git a/web/src/app/api/scan/route.ts b/web/src/app/api/scan/route.ts index 654e7fe..c506f47 100644 --- a/web/src/app/api/scan/route.ts +++ b/web/src/app/api/scan/route.ts @@ -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, diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 63194cd..df05037 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -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 = { 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>; - }, [sport, games]); - if (authLoading || !user) { return (
@@ -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. */} - - - {/* Legacy sport tabs — supplementary, kept for the existing - Top Graded / Most Parlayed flows below. */} -
- {SPORT_TABS.map((s) => { - const active = s === sport; - const count = gameCountsBySport[s]; - return ( - - ); - })} -
+ 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. */} + { + 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 */}
) : topGrades.length === 0 ? ( -

No grades yet. The model is waiting on lines.

+ // Session 59 (work-order 2.3) — the real schedule, not passive waiting. +

No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.

) : (
({ 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(['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(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(() => 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). diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index 89f9a90..9881dbe 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -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 = { + '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 )}
- {/* SUB-HEADER */} + {/* SUB-HEADER — Session 59 (3.2): venue drops FIRST on mobile + (.gc-venue is display:none <640px) so time + pitchers never truncate. */}
{g.away.name} · {g.home.name} - · {g.time} - {g.venue && (<> · {g.venue})} + · {g.time} + {g.venue && ( · {g.venue})}
{/* MLB STARTING PITCHERS (Session 42) */} @@ -209,11 +248,29 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
- {/* 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 && (
GAME LINES · {g.lines.length} BOOKS -
+ + {/* Mobile summary (hidden ≥640px via .gl-summary) */} +
+ {g.away.abbr} {bestLine((l) => !!l.bestAway, (l) => l.awayML)} + {g.home.abbr} {bestLine((l) => !!l.bestHome, (l) => l.homeML)} + O/U {bestLine((l) => !!l.bestOU, (l) => l.ou)} + +
+ +
BOOK
{g.away.abbr} ML
{g.home.abbr} ML
@@ -239,7 +296,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks GRADED PROPS {g.playerStrips && g.playerStrips.length > 0 ? (
- {g.playerStrips.map((ps, i) => ( + {stripsToRender.map((ps, i) => ( ))} + {/* Session 59 (2.2) — the rest of the roster expands in place. */} + {collapsed.truncated && !showAllReads && ( + + )} + {collapsed.truncated && showAllReads && ( + + )}
) : g.props && g.props.length > 0 ? (
diff --git a/web/src/components/vyndr/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx index 5020901..eddde53 100644 --- a/web/src/components/vyndr/StatStrip.tsx +++ b/web/src/components/vyndr/StatStrip.tsx @@ -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({
{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 (
{p.stat} {p.line} - Awaiting next scan + {next ? `Grades post ${next}` : 'Awaiting next scan'}
); } diff --git a/web/src/lib/pipelineSchedule.js b/web/src/lib/pipelineSchedule.js new file mode 100644 index 0000000..5884aa6 --- /dev/null +++ b/web/src/lib/pipelineSchedule.js @@ -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 }; diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index de26a51..accdbc2 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -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} gameProps + * @param {Record} gradeIndex + * @param {Record} 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: [],