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:
Kev
2026-07-10 20:17:49 -04:00
parent 2ae8a5697e
commit c8790fde55
27 changed files with 891 additions and 311 deletions
+49 -3
View File
@@ -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 16 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
+26
View File
@@ -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)
+80
View File
@@ -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.
+33
View File
@@ -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 {
+31 -5
View File
@@ -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);
+56 -1
View File
@@ -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');
+68
View File
@@ -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');
});
});
+46
View File
@@ -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.');
});
});
+16 -4
View File
@@ -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)', () => {
+15 -9
View File
@@ -124,16 +124,22 @@ describe('Phase D — ClaimMeter', () => {
});
});
describe('Phase D — Terminal page (real intelligence, not a stub)', () => {
const src = read('app/terminal/page.tsx');
it('is no longer a RouteStub', () => {
expect(src).not.toContain('RouteStub');
// 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');
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');
});
});
+10 -4
View File
@@ -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');
+3 -2
View File
@@ -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}'`));
});
});
+20 -2
View File
@@ -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');
+1 -1
View File
@@ -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" },
+23
View File
@@ -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 });
}
}
+6 -2
View File
@@ -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>
+5 -2
View File
@@ -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 />
+9 -203
View File
@@ -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');
}
+9 -5
View File
@@ -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
View File
@@ -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>
+15 -2
View File
@@ -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>
);
}
+55 -9
View File
@@ -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>
);
}
+21 -2
View File
@@ -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
+49
View File
@@ -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' }, // JulSep
wnba: { months: [10, 11, 0, 1, 2, 3], returns: 'May' }, // NovApr
mlb: { months: [11, 0, 1], returns: 'spring' }, // DecFeb
};
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
View File
@@ -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" 9098 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;
},
+3 -2
View File
@@ -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',