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:
+49
-3
@@ -4,9 +4,55 @@
|
||||
2026-07-10
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v56.0 — The full data audit: PropLine/boxscore/pipeline inventory,
|
||||
rbis→rbi fix, +6 MLB markets, doubles/outs wired end-to-end, ntfy alerting +
|
||||
retry + missed-cron detection, master roadmap.
|
||||
SHIP BUILD v57.0 — Phase 0 "Kill the Lies" (work order, Jul 10 live audit):
|
||||
every fabricated UI element deleted or rewired to real data. Next: Phase 1
|
||||
(ledger persistence + settlement — the truth infrastructure).
|
||||
|
||||
## Session 57 (2026-07-10) — SHIPPED ✅ PHASE 0: KILL THE LIES
|
||||
|
||||
Backend 2289 → **2309 tests** (+20), 199 suites. Web build clean (exit 0).
|
||||
Spec: `specs/phase-0-kill-the-lies.md`. Work order: Phase 0 of the
|
||||
overhaul (audit-driven; Phases 1–6 queued).
|
||||
|
||||
### 0.1 — Fake NBA game (root cause found + fixed at the source)
|
||||
- `scheduleService.fetchScheduleFromEspn` hit ESPN with NO `?dates=` param and
|
||||
NO date filter → off-season ESPN returns the NEAREST slate (the Jun 13 NYK@SA
|
||||
Finals game rendered as "tonight" all July). Now pins `?dates=YYYYMMDD` AND
|
||||
filters events to the requested ET date; undated events dropped.
|
||||
- Honest per-sport empty states: `web/src/lib/emptyState.js` (month-aware —
|
||||
"NBA returns in October." vs in-season "No NBA games today."), used by the
|
||||
dashboard NO SLATE block + the Slate empty state.
|
||||
|
||||
### 0.2 — Fake header counters deleted
|
||||
- `liveTick.js` stripped to `{ tick }` (1s re-render pulse only). The fake
|
||||
auto-incrementing "247 graded", sin-driven brain-%, aPlus/cascades: DEAD.
|
||||
- New `GET /api/snapshot/summary` (public, cache-only, registered before
|
||||
`/:sport`) → `{ graded, updated_at, sports }`. Next proxy added.
|
||||
- `HeartbeatBar` shows the REAL graded count + SYNC = elapsed since the last
|
||||
pipeline run (amber past 5 min). No data → "SYNC —", nothing invented.
|
||||
|
||||
### 0.3 — Ticker: real items only
|
||||
- Nav's hardcoded fallback items (fake Wembanyama A+/"NYK vs SA Q3"/invented
|
||||
Tatum MOVE) deleted. MOVE stays — `computeLineDeltas` is real movement.
|
||||
- Spec §3 rule: <4 real items → ticker renders nothing. It publishes
|
||||
`--ticker-h` (32px/0px) on `<html>`; layout main paddingTop and Slate sticky
|
||||
top are now `calc(base + var(--ticker-h))` so the header collapses cleanly.
|
||||
|
||||
### 0.4 — /terminal retired (fabricated surface)
|
||||
- Route now `redirect('/dashboard')`. Layouts preserved UNROUTED at
|
||||
`web/src/components/intel/TerminalTemplates.tsx` (§12 content-engine
|
||||
templates — feed real data before ever routing them).
|
||||
- Nav PRIMARY = Slate/Scan/Ledger. BottomTabBar: Terminal→Explore. PWA
|
||||
manifest shortcut Terminal→Ledger. routes.js: `/terminal` out of
|
||||
OPEN_ROUTES; `#terminal` → `/dashboard`.
|
||||
|
||||
### 0.5 — ›Query nav pill deleted (duplicate /scan link)
|
||||
|
||||
### Next (work order Phase 1 — starts the settle clock)
|
||||
- `ledger_entries` table + write-on-grade (user scans AND pipeline pre-grades)
|
||||
- Settlement worker; populate /ledger (personal + MODEL tabs)
|
||||
- Degenerate scan output (model==line, +0% edge) → honest "INSUFFICIENT DATA"
|
||||
- Canonical player_key normalization (blocks 2.4, 4.1)
|
||||
|
||||
## Session 56 (2026-07-10) — SHIPPED ✅ THE FULL AUDIT
|
||||
|
||||
|
||||
@@ -684,6 +684,32 @@ snapshot, locked to the line, and read from cache.
|
||||
transient provider blip). A successful-but-empty slate is NOT retried — that's a
|
||||
legit off-hours empty slate, and retrying would waste quota + add 60s latency.
|
||||
|
||||
## Phase 0 — Kill the Lies (Session 57 — non-obvious)
|
||||
Work-order Phase 0 (Jul 10 live audit): fabricated UI deleted/rewired. The
|
||||
overhaul work order lives in `specs/phase-0-kill-the-lies.md` (+ the full
|
||||
phased plan in the Session-57 conversation / BUILD-STATE Next section).
|
||||
- **ESPN schedule is date-pinned now.** `fetchScheduleFromEspn(sport, date)`
|
||||
sends `?dates=YYYYMMDD` AND filters events to the requested ET date (undated
|
||||
events dropped). Without this, off-season ESPN returns the NEAREST slate —
|
||||
the Jun 13 NYK@SA Finals game rendered as "tonight" all July. Don't remove
|
||||
the defensive filter even though the param "should" be enough.
|
||||
- **`liveTick` carries NO display data** — state is `{ tick }` only (1s
|
||||
re-render pulse). Real header numbers come from `GET /api/snapshot/summary`
|
||||
(public, cache-only; registered BEFORE `/:sport` in `routes/snapshot.js`).
|
||||
`vyndrSystems.test.js` fails if fabricated counters reappear.
|
||||
- **Ticker hides below 4 real items** (spec §3) and publishes `--ticker-h`
|
||||
(32px/0px) on `<html>`. Layout main paddingTop = `calc(92px +
|
||||
var(--ticker-h, 0px))`, Slate sticky top = `calc(90px + ...)`. If you change
|
||||
header heights, update BOTH calc bases (they replace the old 124/122
|
||||
constants). No hardcoded fallback ticker items — real exhaust or nothing.
|
||||
- **/terminal redirects to /dashboard.** The old page's layouts (VVI cards,
|
||||
injury wire, leaders) are preserved UNROUTED in
|
||||
`web/src/components/intel/TerminalTemplates.tsx` as §12 content-engine
|
||||
templates — every constant in there is SAMPLE data; never route it as-is.
|
||||
BottomTabBar's 5 tabs are now Slate/Explore/Scan/Ledger/More.
|
||||
- **Empty slates use `web/src/lib/emptyState.js`** (month-aware per-sport
|
||||
copy). It's CommonJS + unit-tested; add sports there, not inline.
|
||||
|
||||
## Active Skills
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Phase 0 — Kill the Lies (Session 57)
|
||||
|
||||
Source: VYNDR work order (Jul 10 2026 live audit × VYNDR-OVERHAUL-SPEC). Every
|
||||
task is a P0 credibility fix — deletion or rewiring of fake elements. No new
|
||||
product features.
|
||||
|
||||
## 0.1 — Delete the fake NBA game
|
||||
|
||||
**Root cause (found):** `scheduleService.fetchScheduleFromEspn` hits the ESPN
|
||||
scoreboard with NO `?dates=` param and NO date filter. In the off-season ESPN
|
||||
returns the *nearest* slate (the Jun 13 NYK@SA Finals game), which every
|
||||
consumer (dashboard legacy section, Slate merge, hasOdds flags) then renders as
|
||||
tonight's game.
|
||||
|
||||
**Fix (backend, at the source):**
|
||||
- `fetchScheduleFromEspn(sport, date)` appends `?dates=YYYYMMDD` to the ESPN
|
||||
scoreboard URL.
|
||||
- Normalized events are filtered to the requested ET date (`gameTime` converted
|
||||
to America/New_York must equal `date`). Events with no parseable date are
|
||||
dropped — live-feed-only, no exceptions.
|
||||
- `getSchedule` passes its `date` through.
|
||||
|
||||
**Fix (frontend):** sport-specific empty-state copy on the dashboard when a
|
||||
sport has zero games (NBA off-season: "NBA returns in October. The model is
|
||||
watching the MLB + WNBA boards tonight.").
|
||||
|
||||
**Acceptance:** `/api/schedule/nba?date=D` never returns an event whose ET date
|
||||
≠ D. Regression test uses the old NYK@SA fixture (event dated D+1) and asserts
|
||||
it is filtered out.
|
||||
|
||||
## 0.2 — Delete the fake header counters
|
||||
|
||||
- `lib/liveTick.js` state trimmed to `{ tick }` — the fake `graded: 247`
|
||||
auto-increment, sin-driven `neural %`, and `aPlus/cascades` counters are
|
||||
deleted. The tick remains solely as the 1s re-render pulse for the clock.
|
||||
- `HeartbeatBar` now fetches **real** data from a new endpoint
|
||||
`GET /api/snapshot/summary` (public, cache-only Redis reads, registered
|
||||
BEFORE `/:sport`): `{ graded, updated_at, sports: {nba,wnba,mlb,soccer} }`.
|
||||
- graded = Σ grades in each `snapshot:{sport}:latest` (fallback
|
||||
`grades:{sport}`). Real and small beats fake and big.
|
||||
- "neural XX%" — deleted entirely, no replacement.
|
||||
- SYNC = elapsed since `updated_at` (counts up; amber past 5 min). No
|
||||
snapshot data → "SYNC —", no graded count invented.
|
||||
- Next proxy `web/src/app/api/snapshot/summary/route.ts` (S25 rule).
|
||||
|
||||
**Acceptance:** header numbers change only when the snapshot changes; refresh
|
||||
does not reset them to 247/96%.
|
||||
|
||||
## 0.3 — Ticker: real items only
|
||||
|
||||
- The MOVE pipeline is already real (`computeLineDeltas`: locked line vs
|
||||
current line keyed `nameKey|stat|side`) — kept.
|
||||
- Nav's hardcoded `TICKER_ITEMS` fallback (fake Wembanyama A+ / "NYK vs SA
|
||||
Q3" / fake Tatum MOVE) — deleted. Ticker fallback is now an empty list.
|
||||
- Spec §3 rule: fewer than 4 real items → the ticker bar does not render.
|
||||
The bar sets `--ticker-h` (32px/0px) on `<html>`; layout `main` paddingTop
|
||||
and Slate sticky top become `calc(Xpx + var(--ticker-h))` so the header
|
||||
collapses cleanly.
|
||||
|
||||
## 0.4 — Pull /terminal from nav
|
||||
|
||||
- Entire surface is fabricated (Finals-era sample data) → route now
|
||||
`redirect('/dashboard')`.
|
||||
- Sample layouts preserved (unrouted) at
|
||||
`web/src/components/intel/TerminalTemplates.tsx` for the §12 content engine.
|
||||
- Nav PRIMARY = Slate / Scan / Ledger. BottomTabBar: Terminal tab → Explore.
|
||||
PWA manifest Terminal shortcut → Ledger. `routes.js`: `/terminal` out of
|
||||
OPEN_ROUTES; `#terminal` alias → `/dashboard`.
|
||||
|
||||
## 0.5 — Kill ›Query
|
||||
|
||||
Duplicate link to /scan (`data-search-trigger`) deleted from Nav.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Updated: scheduleRoute (date filter + regression), vyndrSystems (liveTick),
|
||||
vyndrMobile (tabs/manifest), vyndrAppShell (routes/nav), vyndrCoreScreens
|
||||
(terminal redirect), vyndrParityQA (tab list).
|
||||
- New: snapshot summary route integration test; schedule off-date filter test.
|
||||
- Full suite green before ship.
|
||||
@@ -36,6 +36,39 @@ function attachOutcomes(grades, index) {
|
||||
});
|
||||
}
|
||||
|
||||
// Session 57 (Phase 0) — GET /api/snapshot/summary: the HeartbeatBar's honest
|
||||
// data source. Cheap cache-only Redis reads; total graded props in the current
|
||||
// snapshots + the latest pipeline run time. Registered BEFORE /:sport or
|
||||
// Express captures "summary" as a sport.
|
||||
const SUMMARY_SPORTS = ['nba', 'wnba', 'mlb', 'soccer'];
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const reads = await Promise.all(SUMMARY_SPORTS.map(async (sp) => {
|
||||
const snap = await cacheGet(`snapshot:${sp}:latest`);
|
||||
if (snap && Array.isArray(snap.grades)) {
|
||||
return { sport: sp, graded: snap.grades.length, updated_at: snap.updated_at || null };
|
||||
}
|
||||
const env = await cacheGet(`grades:${sp}`);
|
||||
return {
|
||||
sport: sp,
|
||||
graded: env && Array.isArray(env.grades) ? env.grades.length : 0,
|
||||
updated_at: (env && env.updated_at) || null,
|
||||
};
|
||||
}));
|
||||
const graded = reads.reduce((n, r) => n + r.graded, 0);
|
||||
// ISO timestamps sort lexicographically — the max is the latest run.
|
||||
const updated_at = reads.map((r) => r.updated_at).filter(Boolean).sort().pop() || null;
|
||||
const sports = {};
|
||||
for (const r of reads) sports[r.sport] = r.graded;
|
||||
res.set('Cache-Control', 'public, max-age=30');
|
||||
return res.json({ graded, updated_at, sports });
|
||||
} catch (err) {
|
||||
console.error('[snapshot/summary]', err.message);
|
||||
return res.status(200).json({ graded: 0, updated_at: null, sports: {} });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:sport', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
try {
|
||||
|
||||
@@ -87,14 +87,40 @@ function normalizeEvent(ev) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + normalize the ESPN scoreboard for a sport. Free endpoint.
|
||||
* A game's ET date (YYYY-MM-DD) from its ISO start time, or null when the
|
||||
* timestamp is missing/unparseable.
|
||||
*/
|
||||
async function fetchScheduleFromEspn(sport) {
|
||||
function gameDateET(iso) {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso);
|
||||
if (Number.isNaN(t.getTime())) return null;
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + normalize the ESPN scoreboard for a sport. Free endpoint.
|
||||
*
|
||||
* Session 57 (Phase 0) — MUST be pinned to the requested date. Without a
|
||||
* `?dates=` param ESPN returns the NEAREST slate in the off-season (the Jun 13
|
||||
* NYK@SA Finals game kept rendering as "tonight" all July). We pass the param
|
||||
* AND filter defensively: only events whose ET date matches `date` survive;
|
||||
* events with no parseable date are dropped — the slate derives from the live
|
||||
* feed only, never from whatever ESPN felt like returning.
|
||||
*/
|
||||
async function fetchScheduleFromEspn(sport, date) {
|
||||
const cfg = getSportConfig(sport);
|
||||
if (!cfg || !cfg.espnScoreboard) return null;
|
||||
const res = await axios.get(cfg.espnScoreboard, { timeout: HTTP_TIMEOUT_MS });
|
||||
const sep = cfg.espnScoreboard.includes('?') ? '&' : '?';
|
||||
const url = date
|
||||
? `${cfg.espnScoreboard}${sep}dates=${String(date).replace(/-/g, '')}`
|
||||
: cfg.espnScoreboard;
|
||||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
|
||||
const events = res.data?.events || [];
|
||||
return events.map(normalizeEvent).filter(Boolean);
|
||||
const games = events.map(normalizeEvent).filter(Boolean);
|
||||
return date ? games.filter((g) => gameDateET(g.gameTime) === date) : games;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +137,7 @@ async function getSchedule(sport, date) {
|
||||
if (cached !== null) return cached;
|
||||
|
||||
try {
|
||||
const games = await fetchScheduleFromEspn(sport);
|
||||
const games = await fetchScheduleFromEspn(sport, date);
|
||||
if (Array.isArray(games)) {
|
||||
await cacheSet(key, games, SCHEDULE_TTL);
|
||||
await cacheSet(`${key}:stale`, games, STALE_TTL);
|
||||
|
||||
@@ -28,12 +28,16 @@ function mountApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
// NOTE (Session 57, Phase 0): the event date must fall on the REQUESTED ET
|
||||
// date (2026-06-12) — 00:40Z Jun 13 = 20:40 ET Jun 12. The old fixture was
|
||||
// dated a day later and the route served it anyway; that exact hole is how the
|
||||
// Jun 13 NYK@SA Finals game kept rendering as "tonight" all July.
|
||||
const ESPN_NBA = {
|
||||
data: {
|
||||
events: [
|
||||
{
|
||||
id: '401234567',
|
||||
date: '2026-06-14T00:40:00Z',
|
||||
date: '2026-06-13T00:40:00Z',
|
||||
status: { type: { state: 'pre' } },
|
||||
competitions: [{
|
||||
venue: { fullName: 'Frost Bank Center' },
|
||||
@@ -81,6 +85,57 @@ describe('GET /api/schedule/:sport', () => {
|
||||
expect(axios.get.mock.calls.length).toBe(callsAfterFirst); // served from cache
|
||||
});
|
||||
|
||||
test('pins the ESPN fetch to the requested date (?dates=YYYYMMDD)', async () => {
|
||||
axios.get.mockResolvedValue(ESPN_NBA);
|
||||
await request(mountApp()).get('/api/schedule/nba?date=2026-06-12');
|
||||
expect(axios.get.mock.calls[0][0]).toContain('dates=20260612');
|
||||
});
|
||||
|
||||
// Session 57 (Phase 0) regression — in the off-season ESPN returns the
|
||||
// NEAREST slate, not today's. Any event whose ET date ≠ the requested date
|
||||
// must be dropped, or a June Finals game renders as tonight's slate in July.
|
||||
test('filters out events from a different ET date (fake off-season game)', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: {
|
||||
events: [
|
||||
{
|
||||
id: '401999999',
|
||||
date: '2026-06-14T00:30:00Z', // Jun 13 ET — NOT the requested Jun 12
|
||||
status: { type: { state: 'pre' } },
|
||||
competitions: [{
|
||||
competitors: [
|
||||
{ homeAway: 'home', score: '0', team: { displayName: 'San Antonio Spurs', abbreviation: 'SA' } },
|
||||
{ homeAway: 'away', score: '0', team: { displayName: 'New York Knicks', abbreviation: 'NYK' } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.games).toEqual([]);
|
||||
});
|
||||
|
||||
test('drops events with no parseable date (live feed only)', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: {
|
||||
events: [{
|
||||
id: '77', // no `date` field at all
|
||||
status: { type: { state: 'pre' } },
|
||||
competitions: [{
|
||||
competitors: [
|
||||
{ homeAway: 'home', score: '0', team: { displayName: 'A', abbreviation: 'A' } },
|
||||
{ homeAway: 'away', score: '0', team: { displayName: 'B', abbreviation: 'B' } },
|
||||
],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
});
|
||||
const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12');
|
||||
expect(res.body.games).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns empty array (not error) when no games today', async () => {
|
||||
axios.get.mockResolvedValue({ data: { events: [] } });
|
||||
const res = await request(mountApp()).get('/api/schedule/mlb?date=2026-06-12');
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Session 57 (Phase 0) — GET /api/snapshot/summary: the HeartbeatBar's honest
|
||||
// data source. Real graded counts + latest pipeline run time from cache-only
|
||||
// Redis reads; must never invent numbers on a cold cache.
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const store = {};
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: jest.fn(async (k) => (k in store ? store[k] : null)),
|
||||
cacheSet: jest.fn(async (k, v) => { store[k] = v; return true; }),
|
||||
}));
|
||||
|
||||
function mountApp() {
|
||||
delete require.cache[require.resolve('../../src/routes/snapshot')];
|
||||
const snapshotRoutes = require('../../src/routes/snapshot');
|
||||
const app = express();
|
||||
app.use('/api/snapshot', snapshotRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(store)) delete store[k];
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/snapshot/summary', () => {
|
||||
test('sums real graded counts across sports and reports the latest run', async () => {
|
||||
store['snapshot:mlb:latest'] = {
|
||||
updated_at: '2026-07-10T18:00:00.000Z',
|
||||
grades: [{ player: 'A' }, { player: 'B' }, { player: 'C' }],
|
||||
};
|
||||
store['snapshot:wnba:latest'] = {
|
||||
updated_at: '2026-07-10T19:00:00.000Z',
|
||||
grades: [{ player: 'D' }],
|
||||
};
|
||||
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.graded).toBe(4);
|
||||
expect(res.body.updated_at).toBe('2026-07-10T19:00:00.000Z'); // latest run
|
||||
expect(res.body.sports.mlb).toBe(3);
|
||||
expect(res.body.sports.wnba).toBe(1);
|
||||
expect(res.body.sports.nba).toBe(0); // off-season → honest zero
|
||||
});
|
||||
|
||||
test('falls back to the grades:{sport} envelope when no snapshot exists', async () => {
|
||||
store['grades:mlb'] = { updated_at: '2026-07-10T16:00:00.000Z', grades: [{ player: 'A' }, { player: 'B' }] };
|
||||
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||
expect(res.body.graded).toBe(2);
|
||||
expect(res.body.updated_at).toBe('2026-07-10T16:00:00.000Z');
|
||||
});
|
||||
|
||||
test('cold cache → zeros and null timestamp, never invented numbers', async () => {
|
||||
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.graded).toBe(0);
|
||||
expect(res.body.updated_at).toBeNull();
|
||||
});
|
||||
|
||||
test('is not captured by the /:sport route (summary ≠ a sport)', async () => {
|
||||
store['snapshot:mlb:latest'] = { updated_at: 'x', grades: [{ player: 'A' }] };
|
||||
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||
// The /:sport handler would return { sport, grades, deltas }.
|
||||
expect(res.body.sport).toBeUndefined();
|
||||
expect(res.body.grades).toBeUndefined();
|
||||
expect(typeof res.body.graded).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Session 57 (Phase 0) — honest per-sport empty-slate copy (spec §6).
|
||||
// An off-season sport names its return window; an in-season sport calls it an
|
||||
// off-day. Never fixture games, never passive "waiting" language.
|
||||
|
||||
const { emptyStateCopy } = require('../../web/src/lib/emptyState');
|
||||
|
||||
const july = new Date('2026-07-10T12:00:00');
|
||||
const december = new Date('2026-12-10T12:00:00');
|
||||
const march = new Date('2026-03-10T12:00:00');
|
||||
|
||||
describe('emptyStateCopy', () => {
|
||||
test('NBA in July → off-season, returns in October', () => {
|
||||
const { title } = emptyStateCopy('nba', july);
|
||||
expect(title).toBe('NBA returns in October.');
|
||||
});
|
||||
|
||||
test('NBA in December → in-season off-day', () => {
|
||||
const { title } = emptyStateCopy('nba', december);
|
||||
expect(title).toBe('No NBA games today.');
|
||||
});
|
||||
|
||||
test('WNBA in March → off-season, returns in May', () => {
|
||||
expect(emptyStateCopy('wnba', march).title).toBe('WNBA returns in May.');
|
||||
});
|
||||
|
||||
test('WNBA in July → in-season off-day', () => {
|
||||
expect(emptyStateCopy('wnba', july).title).toBe('No WNBA games today.');
|
||||
});
|
||||
|
||||
test('MLB in December → off-season', () => {
|
||||
expect(emptyStateCopy('mlb', december).title).toBe('MLB returns in spring.');
|
||||
});
|
||||
|
||||
test('soccer has no off-season branch → off-day copy', () => {
|
||||
expect(emptyStateCopy('soccer', july).title).toBe('No Soccer games today.');
|
||||
});
|
||||
|
||||
test('unknown sport / "all" tab falls back to the generic line', () => {
|
||||
expect(emptyStateCopy('all', july).title).toBe('No games on the board today.');
|
||||
expect(emptyStateCopy(undefined, july).title).toBe('No games on the board today.');
|
||||
});
|
||||
|
||||
test('handles uppercase sport ids (dashboard passes NBA.toLowerCase(), but be safe)', () => {
|
||||
expect(emptyStateCopy('NBA', july).title).toBe('NBA returns in October.');
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ describe('Phase C — routing config (lib/routes)', () => {
|
||||
});
|
||||
|
||||
it('leaves the landing + free funnel open (dashboard, scan stay public)', () => {
|
||||
['/', '/dashboard', '/scan', '/pricing', '/blog', '/terminal', '/login'].forEach((r) => {
|
||||
['/', '/dashboard', '/scan', '/pricing', '/blog', '/login'].forEach((r) => {
|
||||
expect(routes.isGatedRoute(r)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -37,12 +37,17 @@ describe('Phase C — routing config (lib/routes)', () => {
|
||||
|
||||
it('resolves hash deep-link aliases to real routes', () => {
|
||||
expect(routes.resolveHashAlias('#scan')).toBe('/scan');
|
||||
expect(routes.resolveHashAlias('#terminal')).toBe('/terminal');
|
||||
// Session 57 (Phase 0) — /terminal retired; old links land on the slate.
|
||||
expect(routes.resolveHashAlias('#terminal')).toBe('/dashboard');
|
||||
expect(routes.resolveHashAlias('#slate')).toBe('/dashboard');
|
||||
expect(routes.resolveHashAlias('#nope')).toBeNull();
|
||||
expect(routes.resolveHashAlias('')).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the retired /terminal surface from OPEN_ROUTES', () => {
|
||||
expect(routes.OPEN_ROUTES).not.toContain('/terminal');
|
||||
});
|
||||
|
||||
it('keeps GATED and OPEN route lists disjoint', () => {
|
||||
const overlap = routes.GATED_ROUTES.filter((r) => routes.OPEN_ROUTES.includes(r));
|
||||
expect(overlap).toEqual([]);
|
||||
@@ -77,11 +82,18 @@ describe('Phase C — Nav conversion', () => {
|
||||
expect(src).toContain("fontFamily: 'var(--mono)'");
|
||||
expect(src).toContain("'var(--g-a)'");
|
||||
});
|
||||
it('exposes the primary Slate/Terminal/Scan/Ledger routes', () => {
|
||||
['/dashboard', '/terminal', '/scan', '/ledger'].forEach((href) => {
|
||||
it('exposes the primary Slate/Scan/Ledger routes', () => {
|
||||
['/dashboard', '/scan', '/ledger'].forEach((href) => {
|
||||
expect(src).toContain(href);
|
||||
});
|
||||
});
|
||||
// Session 57 (Phase 0) — Terminal (fabricated surface) and the duplicate
|
||||
// ›Query pill are gone from the nav. Don't re-add either.
|
||||
it('does not link /terminal or render the ›Query duplicate', () => {
|
||||
expect(src).not.toContain("href: '/terminal'");
|
||||
expect(src).not.toContain('data-search-trigger');
|
||||
expect(src).not.toContain('>Query<');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase C — Footer (system voice)', () => {
|
||||
|
||||
@@ -124,16 +124,22 @@ describe('Phase D — ClaimMeter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase D — Terminal page (real intelligence, not a stub)', () => {
|
||||
// Session 57 (Phase 0) — the Terminal surface was fabricated sample data
|
||||
// (Finals-era NBA content rendered as live intelligence). The route now
|
||||
// redirects to /dashboard; the layouts survive UNROUTED as §12 templates.
|
||||
describe('Phase 0 — Terminal retired (fabricated surface)', () => {
|
||||
it('the route redirects to the slate instead of rendering fake intel', () => {
|
||||
const src = read('app/terminal/page.tsx');
|
||||
it('is no longer a RouteStub', () => {
|
||||
expect(src).not.toContain('RouteStub');
|
||||
expect(src).toContain("redirect('/dashboard')");
|
||||
expect(src).not.toContain('INJURY_WIRE');
|
||||
expect(src).not.toContain('Wembanyama');
|
||||
});
|
||||
it('uses the intel-surface and renders the VVI / cascade / leaders sections', () => {
|
||||
expect(src).toContain('intel-surface');
|
||||
expect(src).toContain('VOLATILITY INDEX');
|
||||
expect(src).toContain('INJURY WIRE');
|
||||
expect(src).toContain('GRADEABLE LEADERS');
|
||||
it('preserves the VVI / cascade / leaders layouts as unrouted templates', () => {
|
||||
const tpl = read('components/intel/TerminalTemplates.tsx');
|
||||
expect(tpl).toContain('NOT ROUTED');
|
||||
expect(tpl).toContain('VOLATILITY INDEX');
|
||||
expect(tpl).toContain('INJURY WIRE');
|
||||
expect(tpl).toContain('GRADEABLE LEADERS');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,13 +11,18 @@ const manifest = require('../../web/public/manifest.json');
|
||||
|
||||
describe('Phase F — BottomTabBar (5-tab spec)', () => {
|
||||
const src = read('components/BottomTabBar.tsx');
|
||||
// Session 57 (Phase 0) — Terminal tab retired (fabricated surface);
|
||||
// Explore holds its slot so the bar keeps 5 tabs.
|
||||
it('renders the five spec tabs', () => {
|
||||
['Slate', 'Terminal', 'Scan', 'Ledger', 'More'].forEach((label) => {
|
||||
['Slate', 'Explore', 'Scan', 'Ledger', 'More'].forEach((label) => {
|
||||
expect(src).toContain(`'${label}'`);
|
||||
});
|
||||
});
|
||||
it('routes the primary tabs to real pages', () => {
|
||||
['/dashboard', '/terminal', '/scan', '/ledger'].forEach((href) => expect(src).toContain(href));
|
||||
['/dashboard', '/explore', '/scan', '/ledger'].forEach((href) => expect(src).toContain(href));
|
||||
});
|
||||
it('does not link the retired /terminal surface', () => {
|
||||
expect(src).not.toContain('/terminal');
|
||||
});
|
||||
it('makes Scan the prominent (primary) action in grade-green', () => {
|
||||
expect(src).toContain('primary: true');
|
||||
@@ -90,11 +95,12 @@ describe('Phase F — PWA manifest + viewport (§11)', () => {
|
||||
expect(manifest.theme_color.toLowerCase()).toBe('#06060b');
|
||||
expect(manifest.background_color.toLowerCase()).toBe('#06060b');
|
||||
});
|
||||
it('ships deep-link shortcuts for Slate / Scan / Terminal', () => {
|
||||
it('ships deep-link shortcuts for Slate / Scan / Ledger', () => {
|
||||
const urls = (manifest.shortcuts || []).map((s) => s.url);
|
||||
expect(urls).toContain('/dashboard');
|
||||
expect(urls).toContain('/scan');
|
||||
expect(urls).toContain('/terminal');
|
||||
expect(urls).toContain('/ledger');
|
||||
expect(urls).not.toContain('/terminal'); // retired (Session 57, Phase 0)
|
||||
});
|
||||
it('declares its categories', () => {
|
||||
expect(manifest.categories).toContain('sports');
|
||||
|
||||
@@ -54,9 +54,10 @@ describe('QA.5 — wordmark everywhere', () => {
|
||||
});
|
||||
|
||||
describe('QA.11 — mobile parity (5-tab bar)', () => {
|
||||
it('BottomTabBar declares Slate/Terminal/Scan/Ledger/More', () => {
|
||||
// Session 57 (Phase 0) — Terminal retired; Explore holds its slot.
|
||||
it('BottomTabBar declares Slate/Explore/Scan/Ledger/More', () => {
|
||||
const src = read('components/BottomTabBar.tsx');
|
||||
['Slate', 'Terminal', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`));
|
||||
['Slate', 'Explore', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -130,13 +130,22 @@ describe('Phase G.1 — living-layer tick engine (§8)', () => {
|
||||
liveTick.tick();
|
||||
expect(calls).toBe(2); // unsubscribed
|
||||
});
|
||||
it('advances tick and creeps the graded count, with a fresh state object', () => {
|
||||
it('advances tick with a fresh state object', () => {
|
||||
const first = liveTick.state;
|
||||
for (let i = 0; i < 7; i++) liveTick.tick();
|
||||
expect(liveTick.state.tick).toBe(7);
|
||||
expect(liveTick.state.graded).toBeGreaterThan(first.graded);
|
||||
expect(liveTick.state).not.toBe(first); // new object → React re-renders
|
||||
});
|
||||
// Session 57 (Phase 0) — the fake header counters are dead. The tick engine
|
||||
// must carry NO display data: no auto-incrementing graded count, no
|
||||
// sin-driven neural %. Real numbers come from /api/snapshot/summary only.
|
||||
it('carries no fabricated display counters', () => {
|
||||
liveTick.tick();
|
||||
expect(Object.keys(liveTick.state)).toEqual(['tick']);
|
||||
const src = read('lib/liveTick.js');
|
||||
expect(src).not.toContain('neural');
|
||||
expect(src).not.toContain('247');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase G.4 — checkout (§12)', () => {
|
||||
@@ -155,6 +164,15 @@ describe('Phase G — React glue wired correctly', () => {
|
||||
expect(src).toContain('ekg-track');
|
||||
expect(src).toContain('count-tick'); // LiveNumber pop
|
||||
});
|
||||
// Session 57 (Phase 0) — the heartbeat's numbers are REAL: graded count +
|
||||
// sync age come from /api/snapshot/summary; the fake neural % is gone.
|
||||
it('HeartbeatBar reads /api/snapshot/summary and shows no neural %', () => {
|
||||
const src = read('components/vyndr/LiveLayer.tsx');
|
||||
expect(src).toContain('/api/snapshot/summary');
|
||||
expect(src).not.toContain('neural');
|
||||
expect(src).toContain('SYNC'); // elapsed-since-snapshot clock
|
||||
expect(src).toContain('updated_at');
|
||||
});
|
||||
it('GlobalHosts registers the design globals + applies prefs on mount', () => {
|
||||
const src = read('components/vyndr/GlobalHosts.tsx');
|
||||
expect(src).toContain('window.__prefs');
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"shortcuts": [
|
||||
{ "name": "The Slate", "short_name": "Slate", "url": "/dashboard", "description": "Tonight's games and graded props" },
|
||||
{ "name": "Scan a prop", "short_name": "Scan", "url": "/scan", "description": "Grade any player prop" },
|
||||
{ "name": "The Terminal", "short_name": "Terminal", "url": "/terminal", "description": "League intelligence hub" }
|
||||
{ "name": "The Ledger", "short_name": "Ledger", "url": "/ledger", "description": "Every grade, every result" }
|
||||
],
|
||||
"icons": [
|
||||
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
{summary !== null && (
|
||||
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
|
||||
<LiveNumber value={live.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
|
||||
<LiveNumber value={summary.graded} style={{ color: 'var(--text-0)', fontWeight: 700 }} /> graded
|
||||
</span>
|
||||
<span className="mono" style={{ color: 'var(--text-1)', flexShrink: 0 }}>
|
||||
neural <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{live.neural}%</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-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