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:
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/** Snapshot proxy (Session 45) — forwards GET /api/snapshot/:sport (pre-graded slate). */
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ sport: string }> }) {
|
||||
const { sport } = await ctx.params;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/snapshot/${encodeURIComponent(sport)}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ grades: [], deltas: [] }));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ sport, grades: [], deltas: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Ticker proxy (Session 45) — forwards GET /api/ticker to Express (snapshot
|
||||
* exhaust + editorial pins). Thin pass-through; the page polls this every 30s.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/ticker`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ items: [] }));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ items: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,11 @@ export interface StripProp {
|
||||
stat: string;
|
||||
line: number | string;
|
||||
side: string; // O / U / Over / Under
|
||||
grade: string;
|
||||
grade: string | null;
|
||||
// Session 45 — pre-graded snapshot model: the locked grade + market movement.
|
||||
gradedAt?: { line: number; odds?: number | null; timestamp?: string; ago?: string } | null;
|
||||
delta?: { delta: number; direction: 'toward' | 'away'; currentLine: number } | null;
|
||||
awaiting?: boolean; // not yet graded by a snapshot → "Awaiting next scan"
|
||||
}
|
||||
export interface StripArchetype {
|
||||
primary: string;
|
||||
@@ -127,19 +131,59 @@ export default function StatStrip({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{props && props.length > 0 && (
|
||||
<div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', overflowX: 'auto' }}>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)', letterSpacing: '0.06em' }}>PROPS</span>
|
||||
{props.map((p, i) => (
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
||||
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
|
||||
{p.stat} {p.side}{p.line} <GradeBadge grade={p.grade} size="sm" />
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{props && props.length > 0 && (() => {
|
||||
// Session 45 — snapshot mode renders each prop as a block with its
|
||||
// locked grade + market-movement sub-line; otherwise the inline chip row.
|
||||
const snapshotMode = props.some((p) => p.gradedAt || p.delta || p.awaiting);
|
||||
if (!snapshotMode) {
|
||||
return (
|
||||
<div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', overflowX: 'auto' }}>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)', letterSpacing: '0.06em' }}>PROPS</span>
|
||||
{props.map((p, i) => (
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
||||
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
|
||||
{p.stat} {p.side}{p.line} {p.grade && <GradeBadge grade={p.grade} size="sm" />}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{props.map((p, i) => {
|
||||
if (p.awaiting) {
|
||||
return (
|
||||
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ color: 'var(--text-1)' }}>{p.stat} {p.line}</span>
|
||||
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>Awaiting next scan</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const toward = p.delta?.direction === 'toward';
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}>
|
||||
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
|
||||
{p.grade && <GradeBadge grade={p.grade} size="sm" />}
|
||||
{p.gradedAt?.ago && (
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{p.delta && (
|
||||
<div className="mono" style={{ fontSize: 11, color: toward ? 'var(--g-a)' : 'var(--amber)' }}>
|
||||
Current {p.delta.currentLine} · {p.delta.delta > 0 ? '▲' : '▼'} {toward ? 'TOWARD' : 'AWAY'} {p.delta.delta > 0 ? '+' : ''}{p.delta.delta}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type TickerItem = {
|
||||
tag: string;
|
||||
text: string;
|
||||
@@ -12,31 +16,58 @@ type TickerItem = {
|
||||
type TickerProps = {
|
||||
items: TickerItem[];
|
||||
height?: number;
|
||||
/** Poll /api/ticker for live snapshot events (Session 45). Default true. */
|
||||
live?: boolean;
|
||||
/** Poll interval ms (default 30s). */
|
||||
pollMs?: number;
|
||||
};
|
||||
|
||||
const TAG_COLORS: Record<string, string> = {
|
||||
'A+': 'var(--g-ap)', A: 'var(--g-a)', SCAN: 'var(--g-a)',
|
||||
MOVE: 'var(--amber)', CASCADE: 'var(--amber)', ALERT: 'var(--text-0)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrolling marquee (§5). Continuous `ticker-scroll`; content duplicated so the
|
||||
* loop is seamless. Edge fades mask the wrap. Tags glow; values stay crisp.
|
||||
* loop is seamless. Session 45 — polls /api/ticker for live snapshot exhaust
|
||||
* (top grades, line moves, slate-scanned events); the passed `items` are the
|
||||
* initial + graceful fallback so the bar is never empty.
|
||||
*/
|
||||
export default function Ticker({ items, height = 34 }: TickerProps) {
|
||||
const content = items.map((it, i) => (
|
||||
export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
|
||||
const [feed, setFeed] = useState<TickerItem[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!live) return;
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/ticker', { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { items?: TickerItem[] };
|
||||
if (active && Array.isArray(data.items) && data.items.length > 0) {
|
||||
setFeed(data.items.map((it) => ({ ...it, color: it.color || TAG_COLORS[it.tag] || 'var(--amber)' })));
|
||||
}
|
||||
} catch {
|
||||
/* keep the current items on failure — never blank the bar */
|
||||
}
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, pollMs);
|
||||
return () => { active = false; clearInterval(id); };
|
||||
}, [live, pollMs]);
|
||||
|
||||
const display = feed && feed.length > 0 ? feed : items;
|
||||
|
||||
const content = display.map((it, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '0 26px',
|
||||
fontSize: 12,
|
||||
letterSpacing: '0.04em',
|
||||
color: 'var(--text-1)',
|
||||
}}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '0 26px', fontSize: 12, letterSpacing: '0.04em', color: 'var(--text-1)' }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: it.color || 'var(--amber)',
|
||||
textShadow: it.glow ? 'var(--amber-glow)' : 'none',
|
||||
textShadow: it.glow || it.tag === 'A+' ? 'var(--amber-glow)' : 'none',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
@@ -44,9 +75,7 @@ export default function Ticker({ items, height = 34 }: TickerProps) {
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-0)' }}>{it.text}</span>
|
||||
{it.delta && (
|
||||
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>
|
||||
{it.delta}
|
||||
</span>
|
||||
<span style={{ color: it.delta.startsWith('▲') ? 'var(--g-a)' : 'var(--miss)', fontWeight: 700 }}>{it.delta}</span>
|
||||
)}
|
||||
<span style={{ color: 'var(--text-2)' }}>·</span>
|
||||
</span>
|
||||
@@ -54,16 +83,7 @@ export default function Ticker({ items, height = 34 }: TickerProps) {
|
||||
return (
|
||||
<div
|
||||
className="scanlines"
|
||||
style={{
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg-1)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
style={{ height, overflow: 'hidden', background: 'var(--bg-1)', borderTop: '1px solid var(--border)', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', position: 'relative' }}
|
||||
>
|
||||
<div className="ticker-track">
|
||||
{content}
|
||||
|
||||
@@ -192,6 +192,99 @@ function isRelevantGame(game, now = Date.now()) {
|
||||
return (now - t) / 3_600_000 < 24;
|
||||
}
|
||||
|
||||
// ── Pre-graded snapshot overlay (Session 45) ────────────────────────
|
||||
const snorm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const gradeKey = (player, stat) => `${snorm(player)}|${String(stat || '').toLowerCase()}`;
|
||||
const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O');
|
||||
|
||||
/** Index snapshot grades by player|stat → the locked grade record. */
|
||||
function indexGrades(grades) {
|
||||
const map = {};
|
||||
for (const g of grades || []) {
|
||||
map[gradeKey(g.player || g.player_name, g.stat_type || g.stat)] = g;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Index line deltas by player|stat|side → delta record. */
|
||||
function indexDeltas(deltas) {
|
||||
const map = {};
|
||||
for (const d of deltas || []) {
|
||||
map[`${gradeKey(d.player, d.stat)}|${d.side}`] = d;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const STAT_SHORT = {
|
||||
total_bases: 'TB', home_runs: 'HR', hits: 'Hits', rbi: 'RBI', runs: 'Runs',
|
||||
strikeouts: 'Ks', hits_allowed: 'HA', earned_runs: 'ER', innings_pitched: 'IP',
|
||||
stolen_bases: 'SB', points: 'Pts', rebounds: 'Reb', assists: 'Ast', threes: '3PT',
|
||||
steals: 'Stl', blocks: 'Blk', pra: 'PRA', turnovers: 'TO',
|
||||
};
|
||||
function statShort(stat) {
|
||||
if (!stat) return '';
|
||||
return STAT_SHORT[stat] || String(stat).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Relative "Graded Xh ago" from an ISO timestamp. */
|
||||
function gradedAgo(iso, now = Date.now()) {
|
||||
const t = iso ? new Date(iso).getTime() : NaN;
|
||||
if (Number.isNaN(t)) return '';
|
||||
const mins = Math.max(0, Math.round((now - t) / 60000));
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.round(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now()) {
|
||||
const byPlayer = {};
|
||||
const order = [];
|
||||
for (const p of gameProps || []) {
|
||||
if (!p || !p.player) continue;
|
||||
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)];
|
||||
if (!byPlayer[p.player]) {
|
||||
byPlayer[p.player] = {
|
||||
player: p.player,
|
||||
team: p.team || '',
|
||||
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
||||
stats: [],
|
||||
props: [],
|
||||
};
|
||||
order.push(p.player);
|
||||
} else if (!byPlayer[p.player].archetype && rec && rec.archetype) {
|
||||
byPlayer[p.player].archetype = { primary: rec.archetype };
|
||||
}
|
||||
if (rec) {
|
||||
const side = sideCh(rec.direction);
|
||||
const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`];
|
||||
byPlayer[p.player].props.push({
|
||||
stat: statShort(rec.stat_type || rec.stat),
|
||||
line: rec.line,
|
||||
side,
|
||||
grade: rec.grade,
|
||||
gradedAt: rec.gradedAt
|
||||
? { ...rec.gradedAt, ago: gradedAgo(rec.gradedAt.timestamp, now) }
|
||||
: null,
|
||||
delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null,
|
||||
});
|
||||
} else {
|
||||
byPlayer[p.player].props.push({
|
||||
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return order.map((name) => byPlayer[name]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseAmericanOdds,
|
||||
detectBestLines,
|
||||
@@ -201,4 +294,9 @@ module.exports = {
|
||||
groupPropsByPlayer,
|
||||
mapPitchers,
|
||||
isRelevantGame,
|
||||
indexGrades,
|
||||
indexDeltas,
|
||||
statShort,
|
||||
gradedAgo,
|
||||
buildPlayerStripsFromProps,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user