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:
+48
-4
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Session 59 (work-order 2.3) — the REAL pipeline schedule, for honest
|
||||||
|
waiting states. "Awaiting next scan" told the user nothing; the truth is
|
||||||
|
the cron fires at fixed UTC hours, so we can say "Grades post ~6:00 PM ET".
|
||||||
|
|
||||||
|
HOURS_UTC mirrors the backend default (snapshotScheduler SNAPSHOT_HOURS_UTC
|
||||||
|
= 14,19,22,1,3). If the env schedule changes, update BOTH places — a wrong
|
||||||
|
time here is a lie, which is worse than the old vague copy.
|
||||||
|
|
||||||
|
CommonJS so the plain-JS Jest suite can require it directly.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const HOURS_UTC = [14, 19, 22, 1, 3];
|
||||||
|
|
||||||
|
/** The next scheduled run strictly after `now`, as a Date. */
|
||||||
|
function nextRunAt(now = new Date(), hours = HOURS_UTC) {
|
||||||
|
const candidates = [];
|
||||||
|
for (let dayOffset = 0; dayOffset <= 1; dayOffset += 1) {
|
||||||
|
for (const h of hours) {
|
||||||
|
const d = new Date(now.getTime());
|
||||||
|
d.setUTCDate(d.getUTCDate() + dayOffset);
|
||||||
|
d.setUTCHours(h, 0, 0, 0);
|
||||||
|
if (d.getTime() > now.getTime()) candidates.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
return candidates[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "~6:00 PM ET" for the next run. Empty string when the schedule is unknown. */
|
||||||
|
function nextRunLabelET(now = new Date(), hours = HOURS_UTC) {
|
||||||
|
const next = nextRunAt(now, hours);
|
||||||
|
if (!next) return '';
|
||||||
|
const t = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit',
|
||||||
|
}).format(next);
|
||||||
|
return `~${t} ET`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { HOURS_UTC, nextRunAt, nextRunLabelET };
|
||||||
@@ -241,14 +241,37 @@ function gradedAgo(iso, now = Date.now()) {
|
|||||||
return `${Math.round(hrs / 24)}d ago`;
|
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: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user