Session 45: Snapshot pipeline + GameCard swap + live ticker (2100 tests)

The on-demand "Read" grade model is RETIRED. A scheduled pipeline pre-grades the
slate, locks grades to the line, tracks movement; the dashboard shows them already
there. Orchestrates existing services — nothing rebuilt.

- snapshotService.runSnapshot(sport): getOdds → gradeAndCacheSlate → classify
  archetype per player → lock gradedAt → line deltas vs previous snapshot → write
  snapshot:{sport}:latest/previous + grades:{sport} → ticker events. Fully
  injectable, zero-network unit tests. runAllSnapshots = cron entrypoint.
- Internal trigger POST /api/internal/snapshot/:sport + /all (requireInternalAuth).
  In-process cron (SNAPSHOT_CRON=1, UTC 14,19,22,1,3) in server.js, no new dep.
- Public reads: GET /api/snapshot/:sport (cache-only) + GET /api/ticker (merges
  TICKER_MANUAL pins) + Next proxies.
- GameCard swap: live Slate renders vyndr/GameCard (legacy kept for types only),
  overlays locked grades onto game props → player name once + archetype badge +
  "Graded Xh ago at -115 · Current 2.5 · ▲ TOWARD +1.0". Ungraded → "Awaiting next
  scan", NO Read button. On-demand onGrade flow deleted.
- Ticker polls /api/ticker every 30s, graceful fallback to hardcoded items.
- NBA/WNBA: espnStatsAdapter free fallback (defensive parse → found:false on shape
  mismatch) wired into resolvePlayerStats after the offline Python service.

Env: PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1, TICKER_MANUAL.
Backend 2061 -> 2100 tests (+39), 173 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-18 21:34:29 -04:00
parent 7969a4971a
commit f8b120c0aa
24 changed files with 1425 additions and 129 deletions
+54 -78
View File
@@ -2,9 +2,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import GameCard, { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { isRelevantGame } from '@/lib/slateAdapter';
// Session 45 — the live Slate now renders the pre-graded snapshot via the
// VYNDR 2.0 card. Legacy GameCard is kept ONLY for its shared types.
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, Tier } from '@/components/PropRow';
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime } from '@/lib/slateAdapter';
import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the
// navigation system; streaks + hot lists layer ON TOP of the odds the
@@ -157,6 +160,31 @@ interface StreakApiRow {
}
interface StreaksResponse { streaks?: StreakApiRow[] }
// Session 45 — pre-graded snapshot response (snapshot:{sport}:latest).
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
// Session 45 — map a merged SlateGame + the pre-graded snapshot indices into the
// VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas).
type GradeIndex = ReturnType<typeof indexGrades>;
type DeltaIndex = ReturnType<typeof indexDeltas>;
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex): GameCardData {
return {
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
sport: g.sport,
live: g.status === 'in',
score: g.score ? { away: g.score.away, home: g.score.home } : undefined,
away: { abbr: g.awayAbbr || g.awayTeam, name: g.awayTeam },
home: { abbr: g.homeAbbr || g.homeTeam, name: g.homeTeam },
time: formatGameTime(g.gameTime),
venue: g.venue,
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex),
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
};
}
// ---- Session 24: schedule + game-lines response shapes ----
interface ScheduleTeam { name?: string | null; abbreviation?: string | null }
interface ScheduleGame {
@@ -321,6 +349,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
// and game lines stay visible regardless (handled inside GameCard).
const [activeStat, setActiveStat] = useState<string>('all');
const [games, setGames] = useState<SlateGame[]>([]);
// Session 45 — merged pre-graded snapshot across the loaded sports.
const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
// Session 26 — per-sport schedule counts for the tab labels, fetched
@@ -333,11 +364,6 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
// games, this becomes a soft inline notice instead of a wall-of-error.
const [oddsNotice, setOddsNotice] = useState(false);
// Grade state — Map keyed by propRowKey.
const [gradedProps, setGradedProps] = useState<Map<string, PropRowResult>>(() => new Map());
const [gradingKey, setGradingKey] = useState<string | null>(null);
const [errorByKey, setErrorByKey] = useState<Record<string, string | undefined>>({});
// Search filter (Phase 3.4 — kept here so the Slate owns its own filtering).
const [searchQuery, setSearchQuery] = useState('');
@@ -385,11 +411,13 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const perSport = await Promise.all(
sportsToFetch.map(async (sport) => {
const oddsUrls = FETCH_URLS[sport] as string[];
const [oddsResults, schedule, lines, streaksRes] = await Promise.all([
const [oddsResults, schedule, lines, streaksRes, snap] = await Promise.all([
Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
// Session 45 — pre-graded snapshot (locked grades + line deltas).
getJson<SnapshotResponse>(`/api/snapshot/${sport}`),
]);
const oddsOk = oddsResults.some((o) => o !== null);
@@ -397,20 +425,26 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const oddsGames = groupByGame(oddsProps, sport);
const scheduleGames = schedule?.games || [];
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks);
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0 };
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0, snapGrades: snap?.grades || [], snapDeltas: snap?.deltas || [] };
}),
);
const allGames: SlateGame[] = [];
const allSnapGrades: SnapshotGrade[] = [];
const allSnapDeltas: SnapshotDelta[] = [];
let anyOddsOk = false;
let anyScheduleShown = false;
for (const s of perSport) {
allGames.push(...s.merged);
allSnapGrades.push(...s.snapGrades);
allSnapDeltas.push(...s.snapDeltas);
if (s.oddsOk) anyOddsOk = true;
if (s.hadSchedule) anyScheduleShown = true;
}
setGames(allGames);
setSnapGrades(allSnapGrades);
setSnapDeltas(allSnapDeltas);
// Odds down but schedule carried the slate → soft notice, not a wall.
if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
@@ -455,58 +489,15 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
return () => { cancelled = true; };
}, []);
// Grading call site. Single source of truth so we never have two
// PropRows in-flight from the same prop (the loadingKey enforces it).
const onGrade = useCallback(async (prop: PropRowProp) => {
const key = propRowKey(prop);
if (gradingKey) return; // already a grade in flight — defer
setGradingKey(key);
setErrorByKey((prev) => ({ ...prev, [key]: undefined }));
try {
const res = await fetch('/api/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
},
body: JSON.stringify({
sport: 'NBA', // overwritten below per game card sport
player: prop.player,
stat: prop.stat_type,
line: prop.line,
direction: prop.direction,
book: prop.book || 'draftkings',
}),
});
const body = (await res.json().catch(() => ({}))) as Record<string, unknown> & { error?: string };
if (!res.ok) {
setErrorByKey((prev) => ({ ...prev, [key]: body.error || `HTTP ${res.status}` }));
return;
}
const result: PropRowResult = {
grade: String(body.grade || 'C'),
confidence: typeof body.confidence === 'number' ? body.confidence : undefined,
edge_pct: typeof body.edge_pct === 'number' ? body.edge_pct : undefined,
reasoning: (body.reasoning as PropRowResult['reasoning']) || undefined,
kill_conditions_triggered: (body.kill_conditions_triggered as PropRowResult['kill_conditions_triggered']) || [],
tier_gated: !!body.tier_gated,
upgrade_hint: typeof body.upgrade_hint === 'string' ? body.upgrade_hint : undefined,
};
setGradedProps((prev) => {
const next = new Map(prev);
next.set(key, result);
return next;
});
} catch {
setErrorByKey((prev) => ({ ...prev, [key]: 'Network error. Try again.' }));
} finally {
setGradingKey(null);
}
}, [gradingKey, session]);
const onUpgrade = useCallback(() => router.push('/pricing'), [router]);
// Session 45 — the on-demand "Read" grade flow is RETIRED. Grades come from
// the scheduled snapshot pipeline (snapshot:{sport}:latest), overlaid onto the
// slate below. The legacy onGrade call site was removed with the legacy card.
// Filter pipeline — searchQuery applied to games + props.
// Session 45 — index the pre-graded snapshot once for the overlay.
const gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]);
const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]);
const filteredGames = useMemo(() => {
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never
// lingers on the dashboard. Upcoming + live always show.
@@ -742,25 +733,10 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
<div style={{ display: 'grid', gap: 16 }}>
{filteredGames.map((g, i) => (
<GameCard
<VyndrGameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
sport={g.sport}
homeTeam={g.homeTeam}
awayTeam={g.awayTeam}
gameTime={g.gameTime}
venue={g.venue}
context={g.context}
props={g.props}
status={g.status}
score={g.score}
gameLines={g.gameLines}
streaks={g.streaks}
gradedProps={gradedProps}
loadingKey={gradingKey}
errorByKey={errorByKey}
tier={tier}
onGrade={(p) => onGrade({ ...p })}
onUpgrade={onUpgrade}
game={slateGameToCardData(g, gradeIndex, deltaIndex)}
onOpen={() => router.push('/scan')}
/>
))}
</div>