Session 57: Phase 0 — Kill the Lies (2309 tests)
Work-order Phase 0 (Jul 10 live audit): every fabricated UI element deleted
or rewired to real data. Deletion sprint — no new product features.
0.1 Fake NBA game: root cause was scheduleService fetching the ESPN
scoreboard with no ?dates= param or date filter — off-season ESPN
returns the NEAREST slate (Jun 13 NYK@SA Finals rendered as tonight).
Now pinned to the requested ET date + defensive filter; undated events
dropped. Honest month-aware per-sport empty states (lib/emptyState.js).
0.2 Fake header counters: liveTick stripped to a bare 1s pulse (the
auto-incrementing "247 graded", sin-driven brain-%, aPlus/cascades are
dead). New GET /api/snapshot/summary (cache-only, before /:sport) +
Next proxy; HeartbeatBar shows the real graded count and SYNC =
elapsed since the last pipeline run (amber past 5 min).
0.3 Ticker: hardcoded fallback items deleted (real snapshot exhaust only);
MOVE kept — computeLineDeltas is real movement. <4 real items → no
ticker; bar publishes --ticker-h so the fixed header collapses cleanly.
0.4 /terminal retired: route redirects to /dashboard; VVI/injury-wire/
leaders layouts preserved unrouted as §12 content-engine templates.
Nav PRIMARY = Slate/Scan/Ledger; BottomTabBar Terminal→Explore; PWA
shortcut Terminal→Ledger; #terminal alias → /dashboard.
0.5 ›Query nav pill deleted (duplicate /scan link).
Backend 2289 → 2309 tests (199 suites), web build exit 0.
Spec: specs/phase-0-kill-the-lies.md. Next: work-order Phase 1 (ledger
persistence + settlement).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Snapshot summary proxy (Session 57, Phase 0) — forwards
|
||||
* GET /api/snapshot/summary (real graded count + last pipeline run) for the
|
||||
* HeartbeatBar. Static segment wins over the [sport] dynamic route.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/snapshot/summary`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ graded: 0, updated_at: null, sports: {} }));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ graded: 0, updated_at: null, sports: {} }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { GradePill } from '@/components/GradeCard';
|
||||
import Slate from '@/components/Slate';
|
||||
// Session 55 — the self-learning loop's track record, live in the header.
|
||||
import { AccuracyBadge } from '@/components/vyndr';
|
||||
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
|
||||
import { emptyStateCopy } from '@/lib/emptyState';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
@@ -325,14 +327,16 @@ export default function DashboardPage() {
|
||||
{games === null ? (
|
||||
<SkeletonRow stacked />
|
||||
) : games.length === 0 ? (
|
||||
// Session 57 (Phase 0) — honest per-sport empty state (spec §6):
|
||||
// an off-season sport says when it returns; in-season = off-day.
|
||||
<div
|
||||
className="surface diagonal-cut tex-scan"
|
||||
style={{ padding: 32, textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>NO SLATE</p>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No games on the slate tonight.</h3>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>{emptyStateCopy(sport.toLowerCase()).title}</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 420 }}>
|
||||
Check the Ledger to review past grades, or explore the Leaderboard.
|
||||
{emptyStateCopy(sport.toLowerCase()).body}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
|
||||
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger →</a>
|
||||
|
||||
@@ -138,9 +138,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<ParlayProvider>
|
||||
<HashRedirect />
|
||||
<Nav />
|
||||
{/* Header = 60px nav + 32px ticker + 30px heartbeat (§8); offset main. */}
|
||||
{/* Header = 60px nav + 30px heartbeat (+2 slack) + a DYNAMIC
|
||||
ticker: the Ticker publishes --ticker-h (32px with ≥4 real
|
||||
items, else 0px — spec §3) so main's offset collapses with
|
||||
it. Keep the 92px base in sync with Nav/HeartbeatBar. */}
|
||||
<AuthGate>
|
||||
<main style={{ paddingTop: 124, minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
|
||||
<main style={{ paddingTop: 'calc(92px + var(--ticker-h, 0px))', minHeight: '100vh', paddingBottom: 80 }}>{children}</main>
|
||||
</AuthGate>
|
||||
<Footer />
|
||||
<ParlayPanel />
|
||||
|
||||
@@ -1,206 +1,12 @@
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
|
||||
export const metadata = { title: 'The Terminal' };
|
||||
|
||||
// Sample league-intelligence dataset (§7 shapes). Real wiring to
|
||||
// scheduleService.getGameSummary (injury cascades) + schedule/odds (leaders)
|
||||
// lands in a later session; the screen + contracts are real now.
|
||||
const INJURY_WIRE = [
|
||||
{
|
||||
player: 'Jamal Murray', team: 'DEN', sport: 'nba', status: 'OUT', injury: 'Hamstring', posted: '2h ago',
|
||||
cascade: [
|
||||
{ player: 'Nikola Jokić', stat: 'Assists', delta: '+3.2% usage', grade: 'A+' },
|
||||
{ player: 'Russell Westbrook', stat: 'Points', delta: '+4.1% usage', grade: 'B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Jeremy Sochan', team: 'SA', sport: 'nba', status: 'OUT', injury: 'Ankle sprain', posted: '3h ago',
|
||||
cascade: [
|
||||
{ player: 'Victor Wembanyama', stat: 'Points', delta: '+3.2% usage', grade: 'A' },
|
||||
{ player: 'Devin Vassell', stat: '3PT Made', delta: '+2.0% usage', grade: 'B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Mookie Betts', team: 'LAD', sport: 'mlb', status: 'GTD', injury: 'Wrist', posted: '40m ago',
|
||||
cascade: [{ player: 'Shohei Ohtani', stat: 'RBIs', delta: '+1.9% lineup', grade: 'C' }],
|
||||
},
|
||||
];
|
||||
|
||||
const IMPACTED_GAMES = [
|
||||
{ sport: 'nba', match: 'LAL @ SA', time: '10:30 PM ET', vvi: 92, graded: 7, note: 'Sochan OUT spikes Wembanyama usage; LAL pace + 26th-vs-C matchup compound it.', drivers: ['INJURY CASCADE', 'PACE', 'MATCHUP', 'BLOWOUT RISK'] },
|
||||
{ sport: 'mlb', match: 'LAD @ ATL', time: '7:20 PM ET', vvi: 78, graded: 5, note: 'Truist Park boosts LH power; Ohtani platoon edge vs RHP, ATL bullpen on fumes.', drivers: ['PARK FACTOR', 'PLATOON', 'BULLPEN FATIGUE'] },
|
||||
{ sport: 'wnba', match: 'NY @ LV', time: '9:00 PM ET', vvi: 71, graded: 4, note: "Wilson 31% usage vs NY's soft interior; Liberty on a back-to-back.", drivers: ['USAGE', 'MATCHUP', 'REST EDGE'] },
|
||||
];
|
||||
|
||||
const FACTOR_PULSE = [
|
||||
{ count: 11, label: 'Injury cascades', hint: 'props recalibrated', color: 'var(--g-b)' },
|
||||
{ count: 8, label: 'Pace mismatches', hint: 'tempo edges', color: 'var(--g-a)' },
|
||||
{ count: 6, label: 'Park / platoon', hint: 'MLB power spots', color: 'var(--g-a)' },
|
||||
{ count: 5, label: 'Blowout risk', hint: 'minutes-capped', color: 'var(--miss)' },
|
||||
];
|
||||
|
||||
const LEADERS: { key: string; rows: { player: string; team: string; val: string; grade: string; gradeable: boolean }[] }[] = [
|
||||
{
|
||||
key: 'NBA · Points',
|
||||
rows: [
|
||||
{ player: 'Nikola Jokić', team: 'DEN', val: '31.2', grade: 'A+', gradeable: true },
|
||||
{ player: 'Victor Wembanyama', team: 'SA', val: '29.4', grade: 'A', gradeable: true },
|
||||
{ player: 'Anthony Edwards', team: 'MIN', val: '28.4', grade: 'A', gradeable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'MLB · Total Bases',
|
||||
rows: [
|
||||
{ player: 'Shohei Ohtani', team: 'LAD', val: '2.25', grade: 'A', gradeable: true },
|
||||
{ player: 'Aaron Judge', team: 'NYY', val: '2.10', grade: 'B', gradeable: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MATCHUP_EXPLOITS = [
|
||||
{ sport: 'nba', team: 'LAL', rank: '26th', stat: 'Points allowed to C', exploit: 'Wembanyama Points O26.5 · A' },
|
||||
{ sport: 'wnba', team: 'NY', rank: '8th', stat: 'Opp paint FG% allowed', exploit: "A'ja Wilson Points O23.5 · A" },
|
||||
{ sport: 'mlb', team: 'ATL', rank: 'T-4th', stat: 'HR/9 to lefties', exploit: 'Ohtani Total Bases O1.5 · A' },
|
||||
];
|
||||
|
||||
const statusColor = (s: string) =>
|
||||
s === 'OUT' ? 'var(--miss)' : s === 'GTD' || s === 'QUESTIONABLE' ? 'var(--amber)' : 'var(--text-1)';
|
||||
|
||||
function vviColor(v: number) {
|
||||
return v >= 85 ? 'var(--g-ap)' : v >= 75 ? 'var(--g-a)' : v >= 65 ? 'var(--g-b)' : 'var(--g-c)';
|
||||
}
|
||||
|
||||
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Session 57 (Phase 0) — /terminal is retired. The surface was fabricated
|
||||
* sample data (Finals-era NBA content rendered as live intelligence); audit
|
||||
* verdict: REBUILD. Its layouts are preserved as §12 content-engine templates
|
||||
* in components/intel/TerminalTemplates.tsx. "The Terminal" survives as brand
|
||||
* language only (pricing copy). Old links land on the real slate.
|
||||
*/
|
||||
export default function TerminalPage() {
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '28px 16px 96px' }}>
|
||||
{/* HEADER */}
|
||||
<header style={{ marginBottom: 22 }}>
|
||||
<SectionHead accent="var(--g-a)">▚ LEAGUE INTELLIGENCE</SectionHead>
|
||||
<h1 className="mono" style={{ fontSize: 30, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 6px' }}>THE TERMINAL</h1>
|
||||
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
|
||||
The context the books hide — injury cascades, volatility, and the spots tonight's slate underprices.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* VVI — IMPACTED GAMES (intel surface) */}
|
||||
<SectionHead style={{ marginBottom: 12 }}>VYNDR VOLATILITY INDEX · MOST-IMPACTED GAMES</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12, marginBottom: 28 }}>
|
||||
{IMPACTED_GAMES.map((g) => (
|
||||
<div key={g.match} className="intel-surface scanlines" style={{ borderRadius: 10, padding: 16, position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport={g.sport} size="sm" />
|
||||
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: '#e8fff4' }}>{g.match}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="mono" style={{ fontSize: 26, fontWeight: 800, lineHeight: 1, color: vviColor(g.vvi), textShadow: '0 0 14px currentColor' }}>{g.vvi}</div>
|
||||
<div className="label" style={{ fontSize: 8.5, color: 'rgba(232,255,244,.5)' }}>VVI</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'rgba(232,255,244,.75)', margin: '10px 0', lineHeight: 1.55 }}>{g.note}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{g.drivers.map((d) => (
|
||||
<span key={d} className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', padding: '2px 6px', borderRadius: 3, color: '#bdf5e2', border: '1px solid rgba(0,255,184,.3)', background: 'rgba(0,0,0,.25)' }}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mono" style={{ marginTop: 10, fontSize: 11, color: 'var(--g-a)' }}>{g.graded} graded props · {g.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
|
||||
{/* INJURY CASCADES */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>INJURY WIRE · CASCADE ANALYSIS</SectionHead>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{INJURY_WIRE.map((w) => (
|
||||
<div key={w.player} style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport={w.sport} size="sm" />
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{w.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{w.team}</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: statusColor(w.status) }}>{w.status}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 10 }}>{w.injury} · {w.posted}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{w.cascade.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)' }}>↳</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{c.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)' }}> · {c.stat} <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>▲ {c.delta}</span></span>
|
||||
</span>
|
||||
<GradeBadge grade={c.grade} size="sm" glow />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* FACTOR PULSE */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>FACTOR PULSE · FIRING NOW</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
{FACTOR_PULSE.map((f) => (
|
||||
<div key={f.label} style={card}>
|
||||
<div className="mono" style={{ fontSize: 28, fontWeight: 800, color: f.color, lineHeight: 1 }}>{f.count}</div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginTop: 6 }}>{f.label}</div>
|
||||
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2)', marginTop: 2 }}>{f.hint}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GRADEABLE LEADERS */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>GRADEABLE LEADERS</SectionHead>
|
||||
{LEADERS.map((grp) => (
|
||||
<div key={grp.key} style={{ ...card, marginBottom: 10 }}>
|
||||
<div className="label" style={{ marginBottom: 9 }}>{grp.key}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{grp.rows.map((r) => (
|
||||
<div key={r.player} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600 }}>{r.player} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{r.team}</span></span>
|
||||
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)' }}>{r.val}</span>
|
||||
<GradeBadge grade={r.grade} size="sm" glow={r.gradeable} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MATCHUP EXPLOITS */}
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<SectionHead style={{ marginBottom: 12 }}>MATCHUP EXPLOITS · UNDERPRICED SPOTS</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{MATCHUP_EXPLOITS.map((m, i) => (
|
||||
<div key={i} style={card}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<SportBadge sport={m.sport} size="sm" />
|
||||
<span className="mono" style={{ fontSize: 13, fontWeight: 700 }}>{m.team}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--miss)', fontWeight: 700 }}>{m.rank}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginBottom: 8 }}>{m.stat}</div>
|
||||
<div className="mono" style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--g-a)' }}>↳ {m.exploit}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* VYNDR 2.0 mobile tab bar (§6, Session 37). The PWA's primary navigation —
|
||||
* 5 tabs: Slate · Terminal · Scan · Ledger · More. Scan is prominent (raised,
|
||||
* 5 tabs: Slate · Explore · Scan · Ledger · More. Scan is prominent (raised,
|
||||
* grade-green) because it's the core action. More opens a bottom sheet with
|
||||
* every secondary route. Hidden ≥768px via globals.css (.mobile-tab-bar).
|
||||
*
|
||||
* Shown for everyone (anon included): Slate/Terminal/Scan are open routes and
|
||||
* Session 57 (Phase 0): the Terminal tab is gone — that surface was fabricated
|
||||
* and now redirects to /dashboard. Explore (real graded-props browser) takes
|
||||
* its slot so the bar keeps 5 tabs.
|
||||
*
|
||||
* Shown for everyone (anon included): Slate/Explore/Scan are open routes and
|
||||
* this is the only mobile nav. Gated routes (Ledger, Account…) bounce through
|
||||
* the client AuthGate when an anon user taps them.
|
||||
*/
|
||||
@@ -24,7 +28,7 @@ type TabDef = {
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: 'slate', label: 'Slate', href: '/dashboard', icon: SlateIcon },
|
||||
{ id: 'terminal', label: 'Terminal', href: '/terminal', icon: TerminalIcon },
|
||||
{ id: 'explore', label: 'Explore', href: '/explore', icon: ExploreIcon },
|
||||
{ id: 'scan', label: 'Scan', href: '/scan', icon: ScanIcon, primary: true },
|
||||
{ id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
|
||||
{ id: 'more', label: 'More', icon: MoreIcon, isSheet: true },
|
||||
@@ -200,10 +204,10 @@ function SlateIcon({ color }: { color: string }) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function TerminalIcon({ color }: { color: string }) {
|
||||
function ExploreIcon({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="M7 9l3 3-3 3" /><path d="M13 15h4" />
|
||||
<circle cx="12" cy="12" r="9" /><path d="M15.5 8.5l-2 5-5 2 2-5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-39
@@ -9,13 +9,16 @@ import NotificationBell from '@/components/NotificationBell';
|
||||
// Nav labels are English literals for now; nav-string i18n lands in Phase G
|
||||
// (Session 38) once the locale dictionaries carry slate/terminal/etc. keys.
|
||||
|
||||
// VYNDR 2.0 nav (§6, Session 34). Primary links are SLATE / TERMINAL / SCAN /
|
||||
// LEDGER; everything else lives under a More dropdown. All nav chrome is
|
||||
// VYNDR 2.0 nav (§6, Session 34). Primary links are SLATE / SCAN / LEDGER;
|
||||
// everything else lives under a More dropdown. All nav chrome is
|
||||
// JetBrains Mono, uppercase, 11px — system language, not SaaS sans. Active
|
||||
// route is grade-green (--g-a); a Ticker runs under the bar.
|
||||
// Session 57 (Phase 0) — TERMINAL removed: the surface was fabricated sample
|
||||
// data (audit verdict REBUILD). Its layouts live on as §12 content-engine
|
||||
// templates in components/intel/TerminalTemplates.tsx; "The Terminal" survives
|
||||
// as brand language only. Don't re-add the link until it's fed real data.
|
||||
const PRIMARY = [
|
||||
{ id: 'slate', label: 'Slate', href: '/dashboard' },
|
||||
{ id: 'terminal', label: 'Terminal', href: '/terminal' },
|
||||
{ id: 'scan', label: 'Scan', href: '/scan' },
|
||||
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
|
||||
];
|
||||
@@ -29,12 +32,10 @@ const MORE = [
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const TICKER_ITEMS = [
|
||||
{ tag: 'A+', text: 'Signal detected · Wembanyama Points', glow: true },
|
||||
{ tag: 'LIVE', text: 'NYK vs SA · Q3 4:22', color: 'var(--live)' },
|
||||
{ tag: 'CASCADE', text: 'Murray OUT → Jokic usage', delta: '▲+12%', color: 'var(--g-b)' },
|
||||
{ tag: 'MOVE', text: 'Tatum o27.5 → o26.5', delta: '▼-1.0', color: 'var(--amber)' },
|
||||
];
|
||||
// Session 57 (Phase 0) — the hardcoded fallback ticker items (fake Wembanyama
|
||||
// signal, "NYK vs SA Q3", invented Tatum move) are DELETED. The ticker renders
|
||||
// only real /api/ticker snapshot exhaust, and hides itself entirely below 4
|
||||
// real items (spec §3). Never seed it with fabricated content again.
|
||||
|
||||
function isActive(pathname: string, href: string) {
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
@@ -175,32 +176,10 @@ export default function Nav() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — search, bell, read meter / plan, avatar */}
|
||||
{/* Right — bell, read meter / plan, avatar. Session 57 (Phase 0):
|
||||
the "›Query" pill was a duplicate link to /scan — deleted. A real
|
||||
⌘K palette is a later nicety (spec §4), not a nav link. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<a
|
||||
href="/scan"
|
||||
title="Query the system"
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'none',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
borderRadius: 7,
|
||||
border: '1px solid var(--border-hi)',
|
||||
background: 'var(--bg-2)',
|
||||
color: 'var(--text-1)',
|
||||
textDecoration: 'none',
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
data-search-trigger
|
||||
>
|
||||
<span style={{ color: 'var(--g-a)' }}>›</span>
|
||||
<span>Query</span>
|
||||
</a>
|
||||
|
||||
{/* Preferences (language / odds format / accessibility) — §9/§10 */}
|
||||
<button
|
||||
onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()}
|
||||
@@ -408,8 +387,9 @@ export default function Nav() {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Ticker + heartbeat under the bar (§8 living layer) */}
|
||||
<Ticker items={TICKER_ITEMS} height={32} />
|
||||
{/* Ticker + heartbeat under the bar (§8 living layer). No fallback
|
||||
items — real snapshot exhaust or nothing. */}
|
||||
<Ticker items={[]} height={32} />
|
||||
<HeartbeatBar />
|
||||
|
||||
<style jsx>{`
|
||||
@@ -423,9 +403,6 @@ export default function Nav() {
|
||||
:global(.nav-mobile-panel) {
|
||||
display: none !important;
|
||||
}
|
||||
:global([data-search-trigger]) {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ 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, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter';
|
||||
import { emptyStateCopy } from '@/lib/emptyState';
|
||||
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
|
||||
@@ -606,7 +607,9 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 122, // clears nav (60) + ticker (32) + heartbeat (30) — Session 38
|
||||
// Clears nav (60) + heartbeat (30) + the dynamic ticker (Session 57:
|
||||
// --ticker-h is 32px only when the ticker has ≥4 real items).
|
||||
top: 'calc(90px + var(--ticker-h, 0px))',
|
||||
zIndex: 5,
|
||||
background: 'var(--bg-0, #0A0A0F)',
|
||||
paddingTop: 12,
|
||||
@@ -805,7 +808,17 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<p>No games published yet today. Check back closer to first pitch / tip-off / kickoff.</p>
|
||||
// Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
|
||||
// off-season sports name their return window; in-season = off-day.
|
||||
(() => {
|
||||
const { title, body } = emptyStateCopy(tab);
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
|
||||
<p>{body}</p>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
|
||||
/* ============================================================
|
||||
RETIRED SURFACE — §12 CONTENT-ENGINE TEMPLATES ONLY. NOT ROUTED.
|
||||
|
||||
Session 57 (Phase 0): the /terminal page shipped with this fabricated
|
||||
sample dataset (Finals-era NBA injuries, invented VVI scores, made-up
|
||||
leaders) rendered as live intelligence — audit verdict: REBUILD. The
|
||||
route now redirects to /dashboard.
|
||||
|
||||
The LAYOUTS below (VVI cards, injury wire, gradeable leaders, matchup
|
||||
exploits) are visually strong and preserved here as templates for the
|
||||
§12 content engine. Feed them REAL data (scheduleService.getGameSummary
|
||||
injuries, snapshot grades, /api/stats/leaders) before routing anything
|
||||
that renders them. Every constant below is SAMPLE data — never let it
|
||||
reach a user-facing surface.
|
||||
============================================================ */
|
||||
const INJURY_WIRE = [
|
||||
{
|
||||
player: 'Jamal Murray', team: 'DEN', sport: 'nba', status: 'OUT', injury: 'Hamstring', posted: '2h ago',
|
||||
cascade: [
|
||||
{ player: 'Nikola Jokić', stat: 'Assists', delta: '+3.2% usage', grade: 'A+' },
|
||||
{ player: 'Russell Westbrook', stat: 'Points', delta: '+4.1% usage', grade: 'B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Jeremy Sochan', team: 'SA', sport: 'nba', status: 'OUT', injury: 'Ankle sprain', posted: '3h ago',
|
||||
cascade: [
|
||||
{ player: 'Victor Wembanyama', stat: 'Points', delta: '+3.2% usage', grade: 'A' },
|
||||
{ player: 'Devin Vassell', stat: '3PT Made', delta: '+2.0% usage', grade: 'B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Mookie Betts', team: 'LAD', sport: 'mlb', status: 'GTD', injury: 'Wrist', posted: '40m ago',
|
||||
cascade: [{ player: 'Shohei Ohtani', stat: 'RBIs', delta: '+1.9% lineup', grade: 'C' }],
|
||||
},
|
||||
];
|
||||
|
||||
const IMPACTED_GAMES = [
|
||||
{ sport: 'nba', match: 'LAL @ SA', time: '10:30 PM ET', vvi: 92, graded: 7, note: 'Sochan OUT spikes Wembanyama usage; LAL pace + 26th-vs-C matchup compound it.', drivers: ['INJURY CASCADE', 'PACE', 'MATCHUP', 'BLOWOUT RISK'] },
|
||||
{ sport: 'mlb', match: 'LAD @ ATL', time: '7:20 PM ET', vvi: 78, graded: 5, note: 'Truist Park boosts LH power; Ohtani platoon edge vs RHP, ATL bullpen on fumes.', drivers: ['PARK FACTOR', 'PLATOON', 'BULLPEN FATIGUE'] },
|
||||
{ sport: 'wnba', match: 'NY @ LV', time: '9:00 PM ET', vvi: 71, graded: 4, note: "Wilson 31% usage vs NY's soft interior; Liberty on a back-to-back.", drivers: ['USAGE', 'MATCHUP', 'REST EDGE'] },
|
||||
];
|
||||
|
||||
const FACTOR_PULSE = [
|
||||
{ count: 11, label: 'Injury cascades', hint: 'props recalibrated', color: 'var(--g-b)' },
|
||||
{ count: 8, label: 'Pace mismatches', hint: 'tempo edges', color: 'var(--g-a)' },
|
||||
{ count: 6, label: 'Park / platoon', hint: 'MLB power spots', color: 'var(--g-a)' },
|
||||
{ count: 5, label: 'Blowout risk', hint: 'minutes-capped', color: 'var(--miss)' },
|
||||
];
|
||||
|
||||
const LEADERS: { key: string; rows: { player: string; team: string; val: string; grade: string; gradeable: boolean }[] }[] = [
|
||||
{
|
||||
key: 'NBA · Points',
|
||||
rows: [
|
||||
{ player: 'Nikola Jokić', team: 'DEN', val: '31.2', grade: 'A+', gradeable: true },
|
||||
{ player: 'Victor Wembanyama', team: 'SA', val: '29.4', grade: 'A', gradeable: true },
|
||||
{ player: 'Anthony Edwards', team: 'MIN', val: '28.4', grade: 'A', gradeable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'MLB · Total Bases',
|
||||
rows: [
|
||||
{ player: 'Shohei Ohtani', team: 'LAD', val: '2.25', grade: 'A', gradeable: true },
|
||||
{ player: 'Aaron Judge', team: 'NYY', val: '2.10', grade: 'B', gradeable: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MATCHUP_EXPLOITS = [
|
||||
{ sport: 'nba', team: 'LAL', rank: '26th', stat: 'Points allowed to C', exploit: 'Wembanyama Points O26.5 · A' },
|
||||
{ sport: 'wnba', team: 'NY', rank: '8th', stat: 'Opp paint FG% allowed', exploit: "A'ja Wilson Points O23.5 · A" },
|
||||
{ sport: 'mlb', team: 'ATL', rank: 'T-4th', stat: 'HR/9 to lefties', exploit: 'Ohtani Total Bases O1.5 · A' },
|
||||
];
|
||||
|
||||
const statusColor = (s: string) =>
|
||||
s === 'OUT' ? 'var(--miss)' : s === 'GTD' || s === 'QUESTIONABLE' ? 'var(--amber)' : 'var(--text-1)';
|
||||
|
||||
function vviColor(v: number) {
|
||||
return v >= 85 ? 'var(--g-ap)' : v >= 75 ? 'var(--g-a)' : v >= 65 ? 'var(--g-b)' : 'var(--g-c)';
|
||||
}
|
||||
|
||||
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
|
||||
|
||||
export default function TerminalIntelTemplates() {
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '28px 16px 96px' }}>
|
||||
{/* HEADER */}
|
||||
<header style={{ marginBottom: 22 }}>
|
||||
<SectionHead accent="var(--g-a)">▚ LEAGUE INTELLIGENCE</SectionHead>
|
||||
<h1 className="mono" style={{ fontSize: 30, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 6px' }}>THE TERMINAL</h1>
|
||||
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
|
||||
The context the books hide — injury cascades, volatility, and the spots tonight's slate underprices.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* VVI — IMPACTED GAMES (intel surface) */}
|
||||
<SectionHead style={{ marginBottom: 12 }}>VYNDR VOLATILITY INDEX · MOST-IMPACTED GAMES</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12, marginBottom: 28 }}>
|
||||
{IMPACTED_GAMES.map((g) => (
|
||||
<div key={g.match} className="intel-surface scanlines" style={{ borderRadius: 10, padding: 16, position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport={g.sport} size="sm" />
|
||||
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: '#e8fff4' }}>{g.match}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="mono" style={{ fontSize: 26, fontWeight: 800, lineHeight: 1, color: vviColor(g.vvi), textShadow: '0 0 14px currentColor' }}>{g.vvi}</div>
|
||||
<div className="label" style={{ fontSize: 8.5, color: 'rgba(232,255,244,.5)' }}>VVI</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'rgba(232,255,244,.75)', margin: '10px 0', lineHeight: 1.55 }}>{g.note}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{g.drivers.map((d) => (
|
||||
<span key={d} className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', padding: '2px 6px', borderRadius: 3, color: '#bdf5e2', border: '1px solid rgba(0,255,184,.3)', background: 'rgba(0,0,0,.25)' }}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mono" style={{ marginTop: 10, fontSize: 11, color: 'var(--g-a)' }}>{g.graded} graded props · {g.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
|
||||
{/* INJURY CASCADES */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>INJURY WIRE · CASCADE ANALYSIS</SectionHead>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{INJURY_WIRE.map((w) => (
|
||||
<div key={w.player} style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport={w.sport} size="sm" />
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{w.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{w.team}</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: statusColor(w.status) }}>{w.status}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 10 }}>{w.injury} · {w.posted}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{w.cascade.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)' }}>↳</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{c.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)' }}> · {c.stat} <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>▲ {c.delta}</span></span>
|
||||
</span>
|
||||
<GradeBadge grade={c.grade} size="sm" glow />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* FACTOR PULSE */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>FACTOR PULSE · FIRING NOW</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
{FACTOR_PULSE.map((f) => (
|
||||
<div key={f.label} style={card}>
|
||||
<div className="mono" style={{ fontSize: 28, fontWeight: 800, color: f.color, lineHeight: 1 }}>{f.count}</div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginTop: 6 }}>{f.label}</div>
|
||||
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2)', marginTop: 2 }}>{f.hint}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GRADEABLE LEADERS */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>GRADEABLE LEADERS</SectionHead>
|
||||
{LEADERS.map((grp) => (
|
||||
<div key={grp.key} style={{ ...card, marginBottom: 10 }}>
|
||||
<div className="label" style={{ marginBottom: 9 }}>{grp.key}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{grp.rows.map((r) => (
|
||||
<div key={r.player} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600 }}>{r.player} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{r.team}</span></span>
|
||||
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)' }}>{r.val}</span>
|
||||
<GradeBadge grade={r.grade} size="sm" glow={r.gradeable} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MATCHUP EXPLOITS */}
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<SectionHead style={{ marginBottom: 12 }}>MATCHUP EXPLOITS · UNDERPRICED SPOTS</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{MATCHUP_EXPLOITS.map((m, i) => (
|
||||
<div key={i} style={card}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<SportBadge sport={m.sport} size="sm" />
|
||||
<span className="mono" style={{ fontSize: 13, fontWeight: 700 }}>{m.team}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--miss)', fontWeight: 700 }}>{m.rank}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginBottom: 8 }}>{m.stat}</div>
|
||||
<div className="mono" style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--g-a)' }}>↳ {m.exploit}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -39,11 +39,56 @@ export function LiveNumber({ value, className = '', style = {} }: { value: numbe
|
||||
|
||||
const EKG = 'M0 12 H10 L13 4 L16 20 L19 12 H30 L33 9 L36 15 L39 12 H50';
|
||||
|
||||
interface SnapshotSummary {
|
||||
graded: number;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
const SUMMARY_POLL_MS = 60_000;
|
||||
const SYNC_AMBER_MS = 5 * 60_000; // §6 SyncClock — amber past 5 min
|
||||
|
||||
/** Elapsed-since-snapshot label: MM:SS under an hour, then Xh Ym. */
|
||||
function syncLabel(elapsedMs: number): string {
|
||||
const s = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||
if (s < 3600) return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
||||
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
/** The system heartbeat (§8): scrolling EKG + SIGNAL LIVE + graded count +
|
||||
* breathing neural % + sync clock. Sits under the ticker. */
|
||||
* sync clock. Sits under the ticker.
|
||||
*
|
||||
* Session 57 (Phase 0) — every number here is REAL now. The graded count is
|
||||
* the snapshot's actual prop count from /api/snapshot/summary (small and true
|
||||
* beats big and fake), the fabricated brain-% stat is gone entirely, and SYNC counts
|
||||
* up from the last real pipeline run (amber once it's >5 min stale). No
|
||||
* summary data → "SYNC —" and no invented count. */
|
||||
export function HeartbeatBar() {
|
||||
const live = useLive();
|
||||
const clock = `${String(Math.floor(live.tick / 60) % 60).padStart(2, '0')}:${String(live.tick % 60).padStart(2, '0')}`;
|
||||
const live = useLive(); // 1s pulse — re-renders the elapsed clock
|
||||
const [summary, setSummary] = useState<SnapshotSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/snapshot/summary', { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
if (active && typeof data?.graded === 'number') {
|
||||
setSummary({ graded: data.graded, updated_at: data.updated_at ?? null });
|
||||
}
|
||||
} catch {
|
||||
/* keep last known real values — never invent */
|
||||
}
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, SUMMARY_POLL_MS);
|
||||
return () => { active = false; clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
void live.tick; // subscription drives the 1s clock re-render
|
||||
const syncedAt = summary?.updated_at ? Date.parse(summary.updated_at) : NaN;
|
||||
const elapsed = Number.isFinite(syncedAt) ? Date.now() - syncedAt : null;
|
||||
const stale = elapsed !== null && elapsed > SYNC_AMBER_MS;
|
||||
return (
|
||||
<div
|
||||
className="scanlines"
|
||||
@@ -77,13 +122,14 @@ export function HeartbeatBar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
|
||||
<LiveNumber value={live.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
|
||||
{summary !== null && (
|
||||
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
|
||||
<LiveNumber value={summary.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
|
||||
</span>
|
||||
)}
|
||||
<span className="mono" style={{ color: stale ? 'var(--amber)' : 'var(--text-2)', flexShrink: 0 }}>
|
||||
SYNC {elapsed !== null ? syncLabel(elapsed) : '—'}
|
||||
</span>
|
||||
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
|
||||
neural <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{live.neural}%</span>
|
||||
</span>
|
||||
<span className="mono" style={{ color: 'var(--text-2)', flexShrink: 0 }}>SYNC {clock}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,11 +27,19 @@ const TAG_COLORS: Record<string, string> = {
|
||||
MOVE: 'var(--amber)', CASCADE: 'var(--amber)', ALERT: 'var(--text-0)',
|
||||
};
|
||||
|
||||
// Spec §3 — fewer than 4 real items → no ticker. An almost-empty marquee
|
||||
// looping the same item reads as fake; hide the bar instead.
|
||||
const MIN_ITEMS = 4;
|
||||
|
||||
/**
|
||||
* Scrolling marquee (§5). Continuous `ticker-scroll`; content duplicated so the
|
||||
* 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.
|
||||
* (top grades, line moves, slate-scanned events).
|
||||
*
|
||||
* Session 57 (Phase 0) — real items only: the hardcoded fallback feed is gone
|
||||
* (callers pass []), and with fewer than MIN_ITEMS real items the bar renders
|
||||
* nothing. Visibility is published as `--ticker-h` on <html> so the fixed
|
||||
* header stack (layout main padding, Slate sticky top) collapses with it.
|
||||
*/
|
||||
export default function Ticker({ items, height = 34, live = true, pollMs = 30_000 }: TickerProps) {
|
||||
const [feed, setFeed] = useState<TickerItem[] | null>(null);
|
||||
@@ -66,6 +74,17 @@ export default function Ticker({ items, height = 34, live = true, pollMs = 30_00
|
||||
}, [live, pollMs]);
|
||||
|
||||
const display = feed && feed.length > 0 ? feed : items;
|
||||
const visible = display.length >= MIN_ITEMS;
|
||||
|
||||
// Publish the bar's height so the fixed-header consumers (layout main
|
||||
// paddingTop, Slate sticky top) stay in sync whether or not the bar shows.
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.style.setProperty('--ticker-h', visible ? `${height}px` : '0px');
|
||||
return () => { document.documentElement.style.setProperty('--ticker-h', '0px'); };
|
||||
}, [visible, height]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const content = display.map((it, i) => (
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* ============================================================
|
||||
Session 57 (Phase 0) — honest per-sport empty-slate copy (spec §6).
|
||||
|
||||
A sport with zero games must say WHY instead of showing a fixture game or
|
||||
passive "waiting" language. Month-aware: an off-season sport names its
|
||||
return window; an in-season sport calls it an off-day. CommonJS so both the
|
||||
.tsx surfaces (allowJs) and the plain-JS Jest suite can load it.
|
||||
============================================================ */
|
||||
|
||||
// Off-season months per sport (0-indexed). Deliberately conservative: only
|
||||
// months where the league is CERTAINLY dark, so an early/late season edge day
|
||||
// reads as an off-day rather than falsely claiming the league is done.
|
||||
const OFF_SEASON = {
|
||||
nba: { months: [6, 7, 8], returns: 'October' }, // Jul–Sep
|
||||
wnba: { months: [10, 11, 0, 1, 2, 3], returns: 'May' }, // Nov–Apr
|
||||
mlb: { months: [11, 0, 1], returns: 'spring' }, // Dec–Feb
|
||||
};
|
||||
|
||||
const LABEL = { nba: 'NBA', wnba: 'WNBA', mlb: 'MLB', soccer: 'Soccer' };
|
||||
|
||||
/**
|
||||
* Copy for an empty slate. `sport` is lowercase ('nba'|'wnba'|'mlb'|'soccer'
|
||||
* |'all'); anything else falls back to the generic line.
|
||||
* Returns { title, body }.
|
||||
*/
|
||||
function emptyStateCopy(sport, date = new Date()) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
const month = date.getMonth();
|
||||
|
||||
const off = OFF_SEASON[sp];
|
||||
if (off && off.months.includes(month)) {
|
||||
return {
|
||||
title: `${LABEL[sp]} returns in ${off.returns}.`,
|
||||
body: 'The offseason board is dark. The model is running the live slates in the meantime.',
|
||||
};
|
||||
}
|
||||
if (LABEL[sp]) {
|
||||
return {
|
||||
title: `No ${LABEL[sp]} games today.`,
|
||||
body: 'Off-day. The board refills with the next slate.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'No games on the board today.',
|
||||
body: 'The board refills with the next slate.',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { emptyStateCopy, OFF_SEASON };
|
||||
+10
-14
@@ -1,16 +1,22 @@
|
||||
/* ============================================================
|
||||
VYNDR 2.0 — living-layer tick engine (§8).
|
||||
ONE interval, many subscribers (fan-out). Drives HeartbeatBar,
|
||||
LiveNumber, NeuralBrain. Plain CommonJS so the React hook imports it
|
||||
ONE interval, many subscribers (fan-out). Drives HeartbeatBar's
|
||||
elapsed-since-snapshot clock. Plain CommonJS so the React hook imports it
|
||||
AND Jest drives it manually via tick().
|
||||
|
||||
Does NOT auto-start on import (would run in SSR/tests). The React hook
|
||||
calls start() on mount; tests call tick() directly. Animations are gated
|
||||
behind reduced-motion in the CSS layer (Session 33), not here — the tick
|
||||
only updates data.
|
||||
|
||||
Session 57 (Phase 0) — the fake counters are DEAD. This used to carry a
|
||||
cosmetically auto-incrementing graded count, a sin-driven brain-%, and
|
||||
invented aPlus/cascades counts. All deleted: real numbers now come from
|
||||
/api/snapshot/summary (see LiveLayer). The tick survives ONLY as the 1s
|
||||
re-render pulse. Do not add display data back here.
|
||||
============================================================ */
|
||||
|
||||
const INITIAL = { tick: 0, graded: 247, neural: 96, aPlus: 6, cascades: 11 };
|
||||
const INITIAL = { tick: 0 };
|
||||
|
||||
const liveTick = {
|
||||
listeners: new Set(),
|
||||
@@ -28,17 +34,7 @@ const liveTick = {
|
||||
|
||||
/** Advance one tick. New state object so React subscribers re-render. */
|
||||
tick() {
|
||||
const prev = this.state;
|
||||
const t = prev.tick + 1;
|
||||
this.state = {
|
||||
tick: t,
|
||||
// graded count creeps up roughly every ~7s
|
||||
graded: prev.graded + (t % 7 === 0 ? 1 : 0),
|
||||
// neural % "breathes" 90–98 deterministically (sin-driven, test-safe)
|
||||
neural: 94 + Math.round(Math.sin(t / 3) * 4),
|
||||
aPlus: prev.aPlus + (t % 23 === 0 ? 1 : 0),
|
||||
cascades: prev.cascades,
|
||||
};
|
||||
this.state = { tick: this.state.tick + 1 };
|
||||
this._notify();
|
||||
return this.state;
|
||||
},
|
||||
|
||||
@@ -26,12 +26,13 @@ const GATED_ROUTES = [
|
||||
];
|
||||
|
||||
/* Open — reachable without auth (marketing + the free funnel + auth itself). */
|
||||
/* Session 57 (Phase 0): '/terminal' removed — the route now server-redirects
|
||||
to /dashboard (fabricated surface retired), so it needs no gate exemption. */
|
||||
const OPEN_ROUTES = [
|
||||
'/',
|
||||
'/dashboard',
|
||||
'/slate',
|
||||
'/scan',
|
||||
'/terminal',
|
||||
'/compare',
|
||||
'/game',
|
||||
'/pricing',
|
||||
@@ -59,7 +60,7 @@ const HASH_ALIASES = {
|
||||
'#slate': '/dashboard',
|
||||
'#dashboard': '/dashboard',
|
||||
'#scan': '/scan',
|
||||
'#terminal': '/terminal',
|
||||
'#terminal': '/dashboard', // retired surface (Session 57) — old links land on the slate
|
||||
'#compare': '/compare',
|
||||
'#ledger': '/ledger',
|
||||
'#tracker': '/tracker',
|
||||
|
||||
Reference in New Issue
Block a user