Session 55: Self-learning loop + real-time layer (2274 tests)
Product overhaul core — the two transformative, differentiated systems: Self-learning loop (Phase 2): outcomeService settles locked snapshot grades against real MLB Stats API results → hit/miss/push, rolling accuracy by grade tier (30d window). Idempotent, injectable, unit-tested. New GET /api/accuracy + /api/ledger/accuracy + internal settle triggers + cron hook. AccuracyBadge (dashboard/scan/landing) is honest — "LEARNING" below MIN_SAMPLE, never a fake number. Settled HIT/MISS chips overlay the live slate. Real-time layer (Phase 1): Slate silent 60s auto-refresh (no flash, no wipe on transient blips) + "SIGNAL LIVE · UPDATED Xs ago" freshness strip; Ticker LIVE badge that flashes on fresh events. Landing (Phase 3): TopSignals shows tonight's real top-3 A-rated grades + live accuracy — the product shown, not described. Founder pricing: FOUNDER_CODE_EXPIRY default 2026-06-30 → 2026-12-31 (had lapsed, disabling every founder code + the ClaimMeter pitch). That expiry — not a tier change — was the real cause of the 4 stripe test failures. Backend 2255 (4 failing) → 2274 (all green; +19 new, +4 fixed). Web build 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,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Accuracy proxy (Session 55) — forwards GET /api/accuracy to Express (the
|
||||
* self-learning loop's rolling track record). Thin pass-through; the dashboard
|
||||
* header + grade card read this. Empty-safe on any upstream failure.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/accuracy`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ overall: null, sports: {} }));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ overall: null, sports: {}, min_sample: 8, updated_at: null }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { GradePill } from '@/components/GradeCard';
|
||||
// existing dashboard sections (Most Parlayed, Recent Reads) stay
|
||||
// below as intelligence layers on top of the raw odds.
|
||||
import Slate from '@/components/Slate';
|
||||
// Session 55 — the self-learning loop's track record, live in the header.
|
||||
import { AccuracyBadge } from '@/components/vyndr';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
@@ -205,6 +207,10 @@ export default function DashboardPage() {
|
||||
{new Date().toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }).toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
{/* Session 55 — the system's live track record. Self-hides while it has
|
||||
no data; reads "LEARNING" until the settled sample is honest. */}
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<AccuracyBadge variant="chip" />
|
||||
{tier === 'free' && scansRemaining != null && (
|
||||
<div
|
||||
className="mono"
|
||||
@@ -220,6 +226,7 @@ export default function DashboardPage() {
|
||||
{scansRemaining}/5 READS · MO
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Session 13 — Browse-first slate. Owns its own sport-tab UI,
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import Hero from '@/components/Hero';
|
||||
import { ClaimMeter } from '@/components/vyndr';
|
||||
// Session 55 — live top A-rated grades pulled from tonight's real snapshot,
|
||||
// with the self-learning loop's accuracy line. The product shown, not described.
|
||||
import TopSignals from '@/components/TopSignals';
|
||||
// Session 17 — game-count strip mounted between the hero and the
|
||||
// existing LivePropsStrip. Shows "X NBA · Y WNBA · Z MLB games
|
||||
// being graded right now" with a signup CTA. Hides itself when
|
||||
@@ -45,6 +48,8 @@ export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
{/* Session 55 — tonight's real top signals + live accuracy (the system works). */}
|
||||
<TopSignals />
|
||||
{/* Founder-seat scarcity meter (§12) */}
|
||||
<div style={{ padding: '0 16px 8px' }}>
|
||||
<ClaimMeter />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ProcessingGrade from '@/components/vyndr/ProcessingGrade';
|
||||
import { AccuracyBadge } from '@/components/vyndr';
|
||||
import type { GradeResultData } from '@/components/vyndr/GradeResultCard';
|
||||
import { mapScanToGradeResult } from '@/lib/gradeAdapter';
|
||||
import { normalizeName, nameKey } from '@/lib/playerName';
|
||||
@@ -736,6 +737,12 @@ export default function ScanPage() {
|
||||
onReadAnother={reset}
|
||||
/>
|
||||
|
||||
{/* Session 55 — the self-learning loop's track record for this sport.
|
||||
"The system learns" — real hit rate on graded props, misses shown. */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<AccuracyBadge variant="chip" sport={sport?.toLowerCase()} />
|
||||
</div>
|
||||
|
||||
{/* Sportsbook hand-off (preserved feature) */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
|
||||
{SPORTSBOOKS.map((b) => (
|
||||
|
||||
@@ -211,6 +211,16 @@ interface GameLinesResponse { games?: Record<string, GameLines> }
|
||||
// Nickname token (last word) — the most stable cross-source identifier
|
||||
// between ESPN full names and odds-api full names ("San Antonio Spurs"
|
||||
// ↔ "spurs"). Falls back to the whole normalized string.
|
||||
// Session 55 — relative freshness label ("updated 12s ago" → "3m ago").
|
||||
function freshLabel(ts: number | null, now: number): string {
|
||||
if (!ts) return '';
|
||||
const s = Math.max(0, Math.round((now - ts) / 1000));
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
return `${Math.round(m / 60)}h ago`;
|
||||
}
|
||||
|
||||
function nickToken(name?: string | null): string {
|
||||
const w = String(name || '').trim().split(/\s+/);
|
||||
const last = w[w.length - 1] || '';
|
||||
@@ -365,6 +375,10 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
// Session 55 — real-time freshness: when the slate last pulled fresh data,
|
||||
// and a ticking clock so "updated Xs ago" advances between polls.
|
||||
const [lastRefreshed, setLastRefreshed] = useState<number | null>(null);
|
||||
const [nowTick, setNowTick] = useState<number>(() => Date.now());
|
||||
// Session 26 — per-sport schedule counts for the tab labels, fetched
|
||||
// ONCE on mount for every schedule-backed sport (free ESPN, cached 60s).
|
||||
// This makes "MLB (15)" / "WNBA (2)" show on their tabs even while the
|
||||
@@ -383,10 +397,14 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
// Schedule is the foundation — games render even when odds are
|
||||
// empty/503. Odds + lines overlay on top. The slate is never empty
|
||||
// just because one provider is down.
|
||||
const fetchSlate = useCallback(async (active: SlateTab) => {
|
||||
setLoading(true);
|
||||
setFetchError(null);
|
||||
setOddsNotice(false);
|
||||
// Session 55 — real-time layer. `silent` background refreshes keep the slate
|
||||
// alive (polling) without the skeleton flash or clearing the current view.
|
||||
const fetchSlate = useCallback(async (active: SlateTab, silent = false) => {
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
setFetchError(null);
|
||||
setOddsNotice(false);
|
||||
}
|
||||
|
||||
// Sports that carry a schedule/streaks feed (ESPN-backed). Soccer
|
||||
// has no schedule endpoint, so it stays odds-only.
|
||||
@@ -457,15 +475,23 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
if (s.hadSchedule) anyScheduleShown = true;
|
||||
}
|
||||
|
||||
// A silent background poll that came back empty (transient blip) must NOT
|
||||
// wipe the current view or flash an error — keep what the user is seeing.
|
||||
if (silent && allGames.length === 0) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setGames(allGames);
|
||||
setSnapGrades(allSnapGrades);
|
||||
setSnapDeltas(allSnapDeltas);
|
||||
setPitcherGames(allPitcherGames);
|
||||
setLastRefreshed(Date.now());
|
||||
|
||||
// Odds down but schedule carried the slate → soft notice, not a wall.
|
||||
if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
|
||||
if (!silent && !anyOddsOk && anyScheduleShown) setOddsNotice(true);
|
||||
// Genuine total failure (no odds, no schedule, anywhere) → error.
|
||||
if (!anyOddsOk && !anyScheduleShown && allGames.length === 0) {
|
||||
if (!silent && !anyOddsOk && !anyScheduleShown && allGames.length === 0) {
|
||||
setFetchError('No games available right now. Check back soon.');
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -473,6 +499,19 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
|
||||
useEffect(() => { fetchSlate(tab); }, [tab, fetchSlate]);
|
||||
|
||||
// Session 55 — auto-refresh: poll the slate every 60s so fresh snapshot grades
|
||||
// + schedule/score updates appear without a page reload. Silent (no skeleton).
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => { fetchSlate(tab, true); }, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [tab, fetchSlate]);
|
||||
|
||||
// A 15s ticking clock so the "updated Xs ago" freshness label stays honest.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNowTick(Date.now()), 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Session 24 — switching sport resets the stat filter. The categories
|
||||
// differ per sport (Points vs Hits), so a stale "points" filter would
|
||||
// silently blank the MLB panels. Always land back on 'all'.
|
||||
@@ -574,6 +613,29 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
paddingBottom: 12,
|
||||
}}
|
||||
>
|
||||
{/* Session 55 — the live signal strip: proves the data is alive. A
|
||||
pulsing dot, the graded-prop count, any in-progress games, and a
|
||||
ticking "updated Xs ago" freshness stamp fed by the 60s poll. */}
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
|
||||
fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-secondary, #8A8A9A)',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--grade-a, #00D4A0)', display: 'inline-block' }} />
|
||||
<span style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>SIGNAL LIVE</span>
|
||||
{snapGrades.length > 0 && (
|
||||
<><span style={{ color: '#3A3A48' }}>·</span><span>{snapGrades.length} PROPS GRADED</span></>
|
||||
)}
|
||||
{games.some((g) => g.status === 'in') && (
|
||||
<><span style={{ color: '#3A3A48' }}>·</span><span style={{ color: 'var(--live, #FF4757)', fontWeight: 700 }}>{games.filter((g) => g.status === 'in').length} LIVE</span></>
|
||||
)}
|
||||
{lastRefreshed && (
|
||||
<><span style={{ color: '#3A3A48' }}>·</span><span title="The slate auto-refreshes every 60 seconds">UPDATED {freshLabel(lastRefreshed, nowTick)}</span></>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GradeBadge, ArchetypeBadge, AccuracyBadge } from '@/components/vyndr';
|
||||
|
||||
/**
|
||||
* TopSignals (Session 55) — the landing hero's live intelligence preview.
|
||||
*
|
||||
* Pulls the top A-rated grades from tonight's REAL snapshot (not a mockup) and
|
||||
* shows them as mini grade cards, with the self-learning loop's live accuracy
|
||||
* line beneath. The product selling itself by working. Self-hides off-hours
|
||||
* (no A-rated grades) so the landing never shows an empty shell.
|
||||
*/
|
||||
|
||||
interface SnapGrade {
|
||||
player?: string;
|
||||
player_name?: string;
|
||||
stat_type?: string;
|
||||
stat?: string;
|
||||
line?: number;
|
||||
direction?: string;
|
||||
grade?: string;
|
||||
confidence?: number;
|
||||
archetype?: string | null;
|
||||
}
|
||||
|
||||
const SPORTS = ['mlb', 'nba', 'wnba'] as const;
|
||||
const STAT_SHORT: Record<string, string> = {
|
||||
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',
|
||||
};
|
||||
function shortStat(s?: string) {
|
||||
if (!s) return '';
|
||||
return STAT_SHORT[s] || s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
const isTop = (g?: string) => g === 'A+' || g === 'A';
|
||||
|
||||
export default function TopSignals() {
|
||||
const [signals, setSignals] = useState<SnapGrade[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
SPORTS.map((sp) =>
|
||||
fetch(`/api/snapshot/${sp}`, { cache: 'no-store' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => null),
|
||||
),
|
||||
);
|
||||
if (!active) return;
|
||||
const all: SnapGrade[] = [];
|
||||
for (const res of results) {
|
||||
const grades = res && Array.isArray(res.grades) ? res.grades : [];
|
||||
for (const g of grades) if (isTop(g.grade)) all.push(g);
|
||||
}
|
||||
all.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
|
||||
setSignals(all.slice(0, 3));
|
||||
} catch {
|
||||
if (active) setSignals([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, 60_000);
|
||||
return () => { active = false; clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// Self-hide off-hours (nothing graded A yet) — never an empty shell.
|
||||
if (!signals || signals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 960, margin: '0 auto', padding: '8px 16px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, letterSpacing: '0.1em', color: 'var(--text-secondary, #8A8A9A)' }}>
|
||||
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--grade-a, #00D4A0)', display: 'inline-block' }} />
|
||||
<span style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>TONIGHT'S TOP SIGNALS</span>
|
||||
<span style={{ color: 'var(--text-tertiary, #707080)' }}>· LIVE FROM THE SLATE</span>
|
||||
</div>
|
||||
<AccuracyBadge variant="inline" />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
|
||||
{signals.map((g, i) => {
|
||||
const player = g.player || g.player_name || '';
|
||||
const side = String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O';
|
||||
return (
|
||||
<a
|
||||
key={`${player}-${i}`}
|
||||
href="/signup"
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'block', textDecoration: 'none', color: 'inherit',
|
||||
padding: 14, borderRadius: 12,
|
||||
background: 'var(--bg-surface, #12121A)',
|
||||
border: '1px solid var(--border, #1A1A24)',
|
||||
borderLeft: '3px solid var(--grade-a, #00D4A0)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8 }}>
|
||||
{g.archetype ? <ArchetypeBadge archetype={g.archetype} size="sm" variant="full" /> : <span style={{ fontSize: 10, color: 'var(--text-tertiary)' }} />}
|
||||
{g.grade && <GradeBadge grade={g.grade} size="sm" />}
|
||||
</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, color: '#fff', fontFamily: 'var(--sans, sans-serif)', marginBottom: 4 }}>{player}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary, #B8BCC8)' }}>
|
||||
{shortStat(g.stat_type || g.stat)} {side}{g.line}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* AccuracyBadge (Session 55) — the self-learning loop made visible.
|
||||
*
|
||||
* Reads the rolling accuracy record (`/api/accuracy`, written by outcomeService
|
||||
* from settled grades vs REAL results) and renders the system's track record.
|
||||
* This is the #1 trust builder: a system that shows its hit rate, including its
|
||||
* misses. Honest by construction — below MIN_SAMPLE it reads "LEARNING" rather
|
||||
* than faking a number. All data (%) is mono per the brand rule; never glitches.
|
||||
*/
|
||||
|
||||
interface Bucket { hits: number; misses: number; pushes: number; total: number; pct: number | null }
|
||||
interface AccuracyRecord {
|
||||
window_days?: number;
|
||||
overall?: Bucket;
|
||||
byGrade?: Record<string, Bucket>;
|
||||
}
|
||||
interface AccuracyResponse {
|
||||
overall?: AccuracyRecord | null;
|
||||
min_sample?: number;
|
||||
}
|
||||
|
||||
const BLANK: Bucket = { hits: 0, misses: 0, pushes: 0, total: 0, pct: null };
|
||||
|
||||
function merge(a?: Bucket, b?: Bucket): Bucket {
|
||||
const x = a || BLANK, y = b || BLANK;
|
||||
const hits = x.hits + y.hits, misses = x.misses + y.misses, pushes = x.pushes + y.pushes;
|
||||
const decided = hits + misses;
|
||||
return { hits, misses, pushes, total: hits + misses + pushes, pct: decided > 0 ? Math.round((hits / decided) * 100) : null };
|
||||
}
|
||||
|
||||
type Variant = 'inline' | 'chip';
|
||||
|
||||
export default function AccuracyBadge({ variant = 'chip', sport }: { variant?: Variant; sport?: string }) {
|
||||
const [rec, setRec] = useState<AccuracyRecord | null>(null);
|
||||
const [minSample, setMinSample] = useState(8);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch('/api/accuracy', { cache: 'no-store' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: AccuracyResponse | null) => {
|
||||
if (!active) return;
|
||||
setMinSample(data?.min_sample ?? 8);
|
||||
// Prefer a specific sport's record when asked; else the overall.
|
||||
const sportRec = sport && data && (data as unknown as { sports?: Record<string, AccuracyRecord> }).sports?.[sport];
|
||||
setRec((sportRec as AccuracyRecord) || data?.overall || null);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => { if (active) setLoaded(true); });
|
||||
return () => { active = false; };
|
||||
}, [sport]);
|
||||
|
||||
if (!loaded || !rec) return null;
|
||||
|
||||
const aRated = merge(rec.byGrade?.['A+'], rec.byGrade?.['A']);
|
||||
const overall = rec.overall || BLANK;
|
||||
const window = rec.window_days || 30;
|
||||
|
||||
// Honest states: A-rated record → overall record → "LEARNING".
|
||||
let label: string;
|
||||
let value: string;
|
||||
let color: string;
|
||||
let calibrated = true;
|
||||
if (aRated.hits + aRated.misses >= minSample && aRated.pct != null) {
|
||||
label = 'A-RATED'; value = `${aRated.pct}% HIT`; color = 'var(--g-a, #00D4A0)';
|
||||
} else if (overall.hits + overall.misses >= minSample && overall.pct != null) {
|
||||
label = 'MODEL'; value = `${overall.pct}% HIT`; color = 'var(--g-a, #00D4A0)';
|
||||
} else {
|
||||
label = 'MODEL'; value = 'LEARNING'; color = 'var(--amber, #FFB347)'; calibrated = false;
|
||||
}
|
||||
|
||||
const title = calibrated
|
||||
? `VYNDR's ${label === 'A-RATED' ? 'A-rated props' : 'graded props'} over the last ${window} days — including misses. The system settles every grade against real results.`
|
||||
: 'The self-learning loop is still collecting settled results. Hit rate appears once the sample is large enough to be honest.';
|
||||
|
||||
if (variant === 'inline') {
|
||||
return (
|
||||
<span className="mono" title={title} style={{ fontSize: 11, letterSpacing: '0.06em', color }}>
|
||||
{label} · {value} {calibrated ? `· ${window}D` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mono"
|
||||
title={title}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 14px', borderRadius: 999,
|
||||
background: 'var(--bg-surface, #12121A)',
|
||||
border: `1px solid ${color}`,
|
||||
fontSize: 12, color,
|
||||
letterSpacing: '0.06em', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 7, height: 7, borderRadius: '50%', background: color,
|
||||
boxShadow: `0 0 6px ${color}`,
|
||||
animation: calibrated ? undefined : 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 700 }}>{label}</span>
|
||||
<span style={{ color: 'var(--text-1, #B8BCC8)' }}>·</span>
|
||||
<span>{value}</span>
|
||||
{calibrated && <><span style={{ color: 'var(--text-2, #707080)' }}>·</span><span style={{ color: 'var(--text-2, #707080)' }}>{window}D</span></>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export interface StripProp {
|
||||
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"
|
||||
// Session 55 — settled outcome from the self-learning loop (once the game is final).
|
||||
outcome?: { result: 'hit' | 'miss' | 'push' | string; actual?: number | null } | null;
|
||||
}
|
||||
export interface StripArchetype {
|
||||
primary: string;
|
||||
@@ -82,6 +84,24 @@ export default function StatStrip({
|
||||
</button>
|
||||
);
|
||||
};
|
||||
// Session 55 — settled outcome chip. The self-learning loop's transparency
|
||||
// moment: show HIT and MISS, with the real stat. Data → mono, never glitches.
|
||||
const OutcomeChip = ({ p }: { p: StripProp }) => {
|
||||
if (!p.outcome || !p.outcome.result) return null;
|
||||
const r = String(p.outcome.result).toLowerCase();
|
||||
const map: Record<string, { label: string; color: string; bg: string }> = {
|
||||
hit: { label: '✓ HIT', color: 'var(--hit, #00D4A0)', bg: 'color-mix(in srgb, var(--g-a, #00D4A0) 16%, transparent)' },
|
||||
miss: { label: '✕ MISS', color: 'var(--miss, #FF4757)', bg: 'color-mix(in srgb, #FF4757 16%, transparent)' },
|
||||
push: { label: 'PUSH', color: 'var(--text-1, #B8BCC8)', bg: 'var(--bg-2, #12121A)' },
|
||||
};
|
||||
const s = map[r] || map.push;
|
||||
const actual = p.outcome.actual != null ? ` (${p.outcome.actual})` : '';
|
||||
return (
|
||||
<span className="mono" title="Settled against the real result" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.04em', color: s.color, background: s.bg, padding: '2px 6px', borderRadius: 4 }}>
|
||||
{s.label}{actual}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
// Session 52 — Push-to-Book teaser on graded props (feature not live yet).
|
||||
const BookItTeaser = ({ p }: { p: StripProp }) => {
|
||||
if (!p.grade) return null;
|
||||
@@ -209,8 +229,9 @@ export default function StatStrip({
|
||||
<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" />}
|
||||
<ParlayBtn p={p} />
|
||||
<BookItTeaser p={p} />
|
||||
<OutcomeChip p={p} />
|
||||
{!p.outcome && <ParlayBtn p={p} />}
|
||||
{!p.outcome && <BookItTeaser p={p} />}
|
||||
{p.gradedAt?.ago && (
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''}
|
||||
|
||||
@@ -35,16 +35,25 @@ const TAG_COLORS: Record<string, string> = {
|
||||
*/
|
||||
export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
|
||||
const [feed, setFeed] = useState<TickerItem[] | null>(null);
|
||||
// Session 55 — flash the LIVE dot when a fresh event slides in (breaking-news feel).
|
||||
const [flash, setFlash] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!live) return;
|
||||
let active = true;
|
||||
let lastHead = '';
|
||||
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) {
|
||||
const head = `${data.items[0]?.tag}|${data.items[0]?.text}`;
|
||||
if (lastHead && head !== lastHead) {
|
||||
setFlash(true);
|
||||
setTimeout(() => { if (active) setFlash(false); }, 2500);
|
||||
}
|
||||
lastHead = head;
|
||||
setFeed(data.items.map((it) => ({ ...it, color: it.color || TAG_COLORS[it.tag] || 'var(--amber)' })));
|
||||
}
|
||||
} catch {
|
||||
@@ -89,7 +98,24 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
|
||||
{content}
|
||||
{content}
|
||||
</div>
|
||||
<div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(90deg, var(--bg-1), transparent)', zIndex: 2 }} />
|
||||
{/* Session 55 — anchored LIVE badge (chrome, not data → may pulse). */}
|
||||
{live && (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 3,
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '0 14px 0 12px',
|
||||
background: 'var(--bg-1)', borderRight: '1px solid var(--border)',
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: '0.1em',
|
||||
color: flash ? 'var(--g-ap, #00ffb8)' : 'var(--g-a, #00D4A0)',
|
||||
transition: 'color 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'currentColor', display: 'inline-block', boxShadow: flash ? '0 0 8px currentColor' : 'none' }} />
|
||||
LIVE
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'absolute', left: live ? 66 : 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(90deg, var(--bg-1), transparent)', zIndex: 2 }} />
|
||||
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 60, background: 'linear-gradient(270deg, var(--bg-1), transparent)', zIndex: 2 }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export { default as ProcessingGrade } from './ProcessingGrade';
|
||||
export { default as GameCard } from './GameCard';
|
||||
export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } from './GameCard';
|
||||
export { default as ClaimMeter } from './ClaimMeter';
|
||||
export { default as AccuracyBadge } from './AccuracyBadge';
|
||||
|
||||
/* Player Intelligence (Session 42) */
|
||||
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
||||
|
||||
@@ -284,6 +284,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
? { ...rec.gradedAt, ago: gradedAgo(rec.gradedAt.timestamp, now) }
|
||||
: null,
|
||||
delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null,
|
||||
// Session 55 — settled outcome from the self-learning loop (hit/miss/push
|
||||
// + actual stat) once the game completes. null until settled.
|
||||
outcome: rec.outcome ? { result: rec.outcome.result, actual: rec.outcome.actual } : null,
|
||||
});
|
||||
} else {
|
||||
byPlayer[pk].props.push({
|
||||
|
||||
Reference in New Issue
Block a user