diff --git a/BUILD-STATE.md b/BUILD-STATE.md
index 5768ffe..a0da096 100755
--- a/BUILD-STATE.md
+++ b/BUILD-STATE.md
@@ -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 ``; 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
diff --git a/CLAUDE.md b/CLAUDE.md
index 61e51f1..ee13ef2 100755
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 ``. 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)
diff --git a/specs/phase-0-kill-the-lies.md b/specs/phase-0-kill-the-lies.md
new file mode 100644
index 0000000..f888ef0
--- /dev/null
+++ b/specs/phase-0-kill-the-lies.md
@@ -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 ``; 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.
diff --git a/src/routes/snapshot.js b/src/routes/snapshot.js
index ae3f8b0..629ebc3 100644
--- a/src/routes/snapshot.js
+++ b/src/routes/snapshot.js
@@ -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 {
diff --git a/src/services/scheduleService.js b/src/services/scheduleService.js
index 34431e0..00cdaa2 100644
--- a/src/services/scheduleService.js
+++ b/src/services/scheduleService.js
@@ -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);
diff --git a/tests/integration/scheduleRoute.test.js b/tests/integration/scheduleRoute.test.js
index e14f5ce..66121f1 100644
--- a/tests/integration/scheduleRoute.test.js
+++ b/tests/integration/scheduleRoute.test.js
@@ -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');
diff --git a/tests/integration/snapshotSummary.test.js b/tests/integration/snapshotSummary.test.js
new file mode 100644
index 0000000..20f1fb2
--- /dev/null
+++ b/tests/integration/snapshotSummary.test.js
@@ -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');
+ });
+});
diff --git a/tests/unit/emptyState.test.js b/tests/unit/emptyState.test.js
new file mode 100644
index 0000000..dd483c3
--- /dev/null
+++ b/tests/unit/emptyState.test.js
@@ -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.');
+ });
+});
diff --git a/tests/unit/vyndrAppShell.test.js b/tests/unit/vyndrAppShell.test.js
index c5387b3..e72096e 100644
--- a/tests/unit/vyndrAppShell.test.js
+++ b/tests/unit/vyndrAppShell.test.js
@@ -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)', () => {
diff --git a/tests/unit/vyndrCoreScreens.test.js b/tests/unit/vyndrCoreScreens.test.js
index 191f4f6..99c3ff7 100644
--- a/tests/unit/vyndrCoreScreens.test.js
+++ b/tests/unit/vyndrCoreScreens.test.js
@@ -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');
});
});
diff --git a/tests/unit/vyndrMobile.test.js b/tests/unit/vyndrMobile.test.js
index 0951b26..b34e377 100644
--- a/tests/unit/vyndrMobile.test.js
+++ b/tests/unit/vyndrMobile.test.js
@@ -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');
diff --git a/tests/unit/vyndrParityQA.test.js b/tests/unit/vyndrParityQA.test.js
index 6b1e4f4..5583c1f 100644
--- a/tests/unit/vyndrParityQA.test.js
+++ b/tests/unit/vyndrParityQA.test.js
@@ -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}'`));
});
});
diff --git a/tests/unit/vyndrSystems.test.js b/tests/unit/vyndrSystems.test.js
index e113ae3..86628c1 100644
--- a/tests/unit/vyndrSystems.test.js
+++ b/tests/unit/vyndrSystems.test.js
@@ -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');
diff --git a/web/public/manifest.json b/web/public/manifest.json
index e79ccc6..c2aa8b1 100644
--- a/web/public/manifest.json
+++ b/web/public/manifest.json
@@ -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" },
diff --git a/web/src/app/api/snapshot/summary/route.ts b/web/src/app/api/snapshot/summary/route.ts
new file mode 100644
index 0000000..6e69491
--- /dev/null
+++ b/web/src/app/api/snapshot/summary/route.ts
@@ -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 });
+ }
+}
diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx
index 49c3bcc..63194cd 100644
--- a/web/src/app/dashboard/page.tsx
+++ b/web/src/app/dashboard/page.tsx
@@ -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 ? (
NO SLATE
-- Check the Ledger to review past grades, or explore the Leaderboard. + {emptyStateCopy(sport.toLowerCase()).body}
- The context the books hide — injury cascades, volatility, and the spots tonight's slate underprices. -
-No games published yet today. Check back closer to first pitch / tip-off / kickoff.
+ // 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 ( + <> +{title}
+{body}
+ > + ); + })() )}+ The context the books hide — injury cascades, volatility, and the spots tonight's slate underprices. +
+