Compare commits

..

5 Commits

Author SHA1 Message Date
builtbykev d10bb4cce2 Session 59: Addendum + work-order 1.6 + Phase 2 + Phase 3 (2352 tests)
Overnight sprint for the Saturday 10 AM ET deploy gate — day one of the
public ledger record locks against freshly posted lines.

Task A — ledger team/opponent (migration 020, applied at 0 rows):
  populated in both write paths from the real feed; opponent only when the
  player's team matches a game participant (never guessed). Roadmap: Phase
  4.5 WNBA ESPN-boxscore settlement (due ~Jul 24) + Phase 5 per-tier
  calibration logged.

Task B — work-order 1.6 CLOSED (canonical player keys):
  - searchPlayer resolves via nameKey; the old matcher deleted accents
    ("Sanchez" with acute -> "snchez") and substring-guessed onto the WRONG
    player (the mismatched last-10 bug). Ambiguous -> null, never guess.
  - Slate JOIN INVARIANT: a graded prop whose player's real team isn't in
    the game is dropped (TB player can't render under MIL@PIT) — locked by
    tests that fail the suite on regression.
  - grades:{sport} TTL 2h -> 6h (expired between 5h cron gaps — the real
    cause of /team "No active props" for slate players).

Task C — Phase 2 slate UX: tabs are THE filter (URL ?sport=, deep-linkable,
  duplicate legacy tablist removed); cards cap at 6 graded props sorted
  A+->F with ALL N READS in-place expander; waiting states show the real
  next pipeline run ("Grades post ~6:00 PM ET").

Task D — Phase 3 mobile P0: root cause of vanished 390px nav was HIDE_ON
  including '/' (landing had zero navigation) — fixed; html/body overflow-x
  contained; GAME LINES collapses to best-line summary + "N BOOKS" expander
  below 640px; venue drops before time/pitchers ever truncate.

Live verification: raw ESPN today STILL returns the Jun 13 NYK@SA Finals
game without a date pin; the pinned fetch returns 0 games, 0 off-date.

Backend 2327 -> 2352 tests (202 suites), web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:24:47 -04:00
builtbykev c96e74c54b Session 59: migration 020 — ledger team/opponent (pre-apply commit)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:02:48 -04:00
builtbykev d296e40cb6 Session 58: Phase 1 — Truth Infrastructure (2327 tests)
ledger_entries is live (migration 019 applied to prod, RLS + NULLS NOT
DISTINCT dedupe verified against the real database). Every grade now
persists, settles against the real result, and carries closing-line value.

- ledgerService: pipeline pre-grade upserts (public model record, user_id
  null, idempotent), closing capture on every snapshot (last write before
  game start = the close), settlement with SIGNED CLV (over = locked -
  closing; beat/faded/flat), 30d model aggregate with the hard n>=20 rule.
- Write paths: snapshotService -> ledger (priority path); Next /api/scan ->
  ledger for authenticated users only (anon never touches the public
  record). Refused reads write nothing and don't burn a scan.
- Honest refusal (work-order 1.5): no projection => insufficient_data,
  grade null, "INSUFFICIENT DATA - no read" UI. The web gradeAdapter no
  longer displays the line as the model projection (the audit's
  model==line / +0% edge degenerate); the card renders absent states.
  projectionFor is sport-aware (l5 -> l20 -> {stat}_per_90 -> xG).
- /ledger: MY READS | MODEL tabs; model header shows hit% + beat-close%
  only at n>=20, else RECORD BUILDING + live pending count. ModelRecord
  deferred-render strip on landing + player hero. CLV + outcome chips,
  revised_from_grade strikethrough (Phase 2.5 ready).
- SYNC (Task 5): thresholds vs SNAPSHOT_EXPECTED_INTERVAL (normal <1.5x,
  amber >=1.5x, STALE red >=3x) via /api/snapshot/summary.
- Phase 2.5 logged in specs/vyndr-roadmap.md (build after Phase 3).
- Data-semantics hardening: strict null-safe numeric parsing everywhere a
  market value is handled (Number(null)===0 would have fabricated lines).

Backend 2309 -> 2327 tests (201 suites), web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:34:26 -04:00
builtbykev 2c79373a3b Session 58: Phase 1 spec + ledger_entries migration (pre-apply commit)
Migration 019: the truth-infrastructure table. Committed BEFORE it runs,
per the Phase 1 GO instructions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:49:28 -04:00
builtbykev c8790fde55 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>
2026-07-10 20:17:49 -04:00
64 changed files with 3293 additions and 593 deletions
+128 -3
View File
@@ -4,9 +4,134 @@
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 v59.0 — Overnight session: ledger team/opponent addendum,
work-order 1.6 closed (canonical player keys + slate join invariant),
Phase 2 slate UX, Phase 3 mobile P0. DEPLOY GATE: Coolify by Sat 10 AM ET;
first snapshot locks against freshly posted lines (manual internal trigger).
## Session 59 (2026-07-10 overnight) — SHIPPED ✅ ADDENDUM + 1.6 + PHASE 2 + PHASE 3
Backend 2327 → **2352 tests** (+25), 202 suites. Web build clean (exit 0).
Migration 020 (team/opponent) APPLIED while ledger_entries was at 0 rows —
zero backfill needed; VYNDR-on-team unblocked.
- **Task A** — `ledger_entries.team/opponent` populated in both write paths
(pipeline: stats-resolve team + prop's other side via nickname match,
NEVER guessed; scan: snapshot-grade team, opponent null). Roadmap: Phase
4.5 (WNBA ESPN box-score settlement — DUE ~Jul 24) + Phase 5 calibration-
by-grade-tier logged.
- **Task B (work-order 1.6 CLOSED)** — `mlbStatsAdapter.searchPlayer` now
resolves via canonical `nameKey` (the old matcher DELETED accents:
"Sánchez"→"snchez"≠"sanchez", then substring-guessed → wrong player's
last-10). Fallback = UNIQUE last-name+first-initial or null.
`buildPlayerStripsFromProps` gained the JOIN INVARIANT: a graded prop
whose player's real team isn't a game participant is DROPPED (TB player
can't render under MIL@PIT — suite fails if it regresses). Snapshot
grades now carry `team`. Root cause of /team "No active props":
grades:{sport} TTL (2h) < cron gap (5h) → now SNAP_TTL (6h).
- **Task C (Phase 2)** — 2.1 sport tabs are THE filter, URL-driven
(?sport=, deep-linkable); the dashboard's duplicate legacy tablist is
GONE (legacy sections subscribe via onTabChange). 2.2 cards cap at 6
graded props sorted A+→F with "ALL N READS →" expanding in place.
2.3 waiting states show the REAL next pipeline run ("Grades post
~6:00 PM ET" via lib/pipelineSchedule — mirrors SNAPSHOT_HOURS_UTC;
update BOTH if the cron changes). 2.4 = TTL fix + join fix (verify on
the live Yankees page post-deploy).
- **Task D (Phase 3)** — ROOT CAUSE of vanished mobile nav: HIDE_ON
included '/' — the landing had NO nav at 390px (desktop links hidden,
hamburger retired S37, tab bar suppressed). '/' removed. 3.2: html/body
overflow-x hidden <768; GAME LINES collapses to a best-line summary +
"N BOOKS ⌄" expander <640; venue (.gc-venue) drops first on phones.
- **Live verification:** raw ESPN (no date pin) STILL returns the Jun 13
NYK@SA Finals game today; the pinned fetch returns 0 games, 0 off-date.
### Morning deploy runbook (Sat, before ~10 AM ET)
1. Push already sent to gitea (Coolify betonblk) — confirm deploy finished.
2. `GET /api/internal/snapshot/status` (x-internal-key) → cron_armed true.
3. When the day's lines post: `POST /api/internal/snapshot/all`
(x-internal-key) → first public ledger rows lock against fresh lines.
4. Verify: `SELECT count(*), count(*) FILTER (WHERE team IS NOT NULL) FROM
ledger_entries WHERE user_id IS NULL;` + one sample row.
## Session 58 (2026-07-10) — SHIPPED ✅ PHASE 1: TRUTH INFRASTRUCTURE
Backend 2309 → **2327 tests** (+18), 201 suites. Web build clean (exit 0).
Spec: `specs/phase-1-truth-infrastructure.md`. Migration
`supabase/migrations/019_ledger_entries.sql` APPLIED to prod (betonblk) —
dedupe (`UNIQUE NULLS NOT DISTINCT`) + RLS verified live (anon reads public
rows only; anon INSERT rejected 42501).
- **ledgerService** — recordPipelineGrades (public model record, idempotent
ignoreDuplicates upsert), captureClosing (every snapshot overwrites today's
closing_line/odds from the real feed; last write before game start = close),
settleLedger (outcome + actual + SIGNED CLV: over = lockedclosing),
getModelAggregate (30d; percentages NULL under 20 settles). No-ops without
SUPABASE env. `Number(null)===0` fabrication bug caught by tests → strict
numOrNull everywhere.
- **Write paths:** snapshotService → ledger (user_id null, priority path);
Next /api/scan → ledger row for AUTHED users only (anon would pollute the
public record). Refused reads write NOTHING (no scan_history either) and
don't burn a scan.
- **Honest refusal (work-order 1.5):** analyzeViaEngine1 returns
`insufficient_data: true, grade: null` when the model has no projection
(l5→l20→{stat}_per_90→xG for soccer). The web gradeAdapter no longer
displays the LINE as the projection (the audit's model==line degenerate);
GradeResultCard renders "—" absent states; scan page renders
"INSUFFICIENT DATA — NO READ".
- **/ledger:** MY READS | MODEL tabs; model header shows "H-M · X% HIT ·
Y% BEAT CLOSE" only at n≥20, else "RECORD BUILDING" + pending count.
ModelRecord component mounted on landing + player hero (deferred-render).
- **SYNC (Task 5):** thresholds vs SNAPSHOT_EXPECTED_INTERVAL (default
18000s): normal <1.5x · amber ≥1.5x · STALE red ≥3x.
- **Phase 2.5 LOGGED** in specs/vyndr-roadmap.md (intraday refresh +
STEAM/VALUE/revision handling; build after Phase 3).
- **Settle trigger:** scheduler settle pass + POST /api/internal/ledger/settle.
## 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
+89
View File
@@ -684,6 +684,95 @@ 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.
## Phase 1 — Truth Infrastructure (Session 58 — non-obvious)
- **DATA SEMANTICS RULE (system-wide):** VYNDR never generates lines/odds —
market values are REAL book numbers captured at a timestamp; only
`model_value`/grade/edge/confidence are model output, always labeled MODEL
in the UI. Any path that would fill in a market value must show an absent
state. `Number(null) === 0` is the classic fabrication bug — use
`numOrNull` (ledgerService) style strict parsing.
- **`ledger_entries`** (migration 019, APPLIED): user_id NULL rows = the
PUBLIC model record (pipeline-only — never write anon scans there!).
Dedupe = `UNIQUE NULLS NOT DISTINCT (user_id, player_key, stat, line,
side, game_id)`; upserts use `ignoreDuplicates: true` so re-runs never
overwrite the original lock. RLS: clients read own rows + public rows;
ALL writes via service role. player_key = `nameKey()`.
- **Write paths:** pipeline → `snapshotService` calls
`ledgerService.recordPipelineGrades` + `captureClosing` (best-effort,
never breaks the snapshot; no-op without SUPABASE env). User scans →
the NEXT `/api/scan` route (`writeLedgerEntry`), authed users only.
- **Closing/CLV:** captureClosing overwrites today's unsettled rows'
closing_line/odds on EVERY snapshot — last write before game start is the
close. CLV is SIGNED BY SIDE: over = locked closing (positive = market
moved toward the grade = 'beat'); under = inverse. Settlement
(`settleLedger`) runs in the scheduler's pre-grade settle pass and via
POST /api/internal/ledger/settle; idempotent (`.is('outcome', null)` guard).
- **Insufficient data (work-order 1.5):** `analyzeViaEngine1` REFUSES
(grade null + `insufficient_data: true`) when `projectionFor` finds no
model reference (l5→l20→`{stat}_per_90`→xG-for-goals). gradeSlateService
filters refusals out of the slate; parlay legs degrade to the F-stub; the
scan route writes nothing and doesn't burn a scan. If you add a sport,
make sure its feature extractor emits a projection field or every prop
refuses.
- **n≥20 rule:** `getModelAggregate` returns `hit_pct`/`beat_close_pct` as
NULL under 20 settles; every surface (ledger MODEL tab, `ModelRecord`)
renders "RECORD BUILDING" instead. Never render a % on a small sample.
- **SYNC thresholds** come from `SNAPSHOT_EXPECTED_INTERVAL` (s; default
18000) via /api/snapshot/summary `expected_interval_s`. Phase 2.5 drops
the value to the intraday cadence — no UI change needed.
## Player Keys + Slate Join + Mobile Nav (Session 59 — non-obvious)
- **`mlbStatsAdapter.searchPlayer` resolves via `nameKey`** (canonical,
accent-folded). NEVER reintroduce substring matching — it returned the
WRONG player's id on a near-miss (the audit's mismatched last-10 bug).
Fallback is unique last-name+first-initial or null: a missing profile
beats another player's log.
- **The slate JOIN INVARIANT** lives in `buildPlayerStripsFromProps(…,
gameTeams)`: a graded prop whose player's real team (snapshot `team`,
from the stats resolve) isn't a game participant is DROPPED. Props with
no team info are kept (can't verify ≠ wrong). Tests in
slateAdapterStrips lock it.
- **grades:{sport} is written with SNAP_TTL (6h), NOT 2h** — the 2h TTL
expired between 5h-apart cron runs and blanked /team + Explore mid-day.
- **Sport tabs are THE filter** (?sport= URL param, history.replaceState).
The dashboard has NO second tablist — legacy sections subscribe via
Slate's `onTabChange`. Don't re-add a duplicate tab row.
- **`lib/pipelineSchedule.js` mirrors SNAPSHOT_HOURS_UTC** (14,19,22,1,3)
for "Grades post ~X ET" waiting states; a test cross-checks it against
snapshotScheduler.HOURS_UTC. Change the cron → change both.
- **BottomTabBar shows on '/'** — hiding it there left anon phones with
ZERO navigation (desktop links hide <768, hamburger retired). HIDE_ON is
auth flows only.
- **GameCard collapse**: 6 graded props max (sorted A+→F), "ALL N READS →"
expands in place; GAME LINES <640px = `.gl-summary` best-line row +
expander revealing `.gl-full.gl-expanded`.
## 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.
+102
View File
@@ -0,0 +1,102 @@
# Phase 1 — Truth Infrastructure (Session 58)
Source: Phase 1 GO + amendments (Kev, Jul 10 2026). Consolidates work-order
Phase 1, the CLV schema, the SYNC threshold fix, and logs Phase 2.5.
## Data semantics rule (system-wide)
VYNDR never generates lines or odds — all market numbers are REAL book values
captured at a timestamp. VYNDR generates only `model_value` (projection) +
grade/edge/confidence, always labeled MODEL in the UI, never blended with
market data. Any path that would fabricate/fill a market value must show an
absent state instead.
## 1. `ledger_entries` (migration 019)
Full column list per the GO prompt (id, user_id nullable = public pipeline
record, player_key, player_name, sport, stat, line, side, locked_odds, book,
grade, edge, confidence, model_value, graded_at, game_id, game_date,
closing_line, closing_odds, clv, clv_result, outcome, actual_value,
settled_at, revised_from_grade).
Decisions (deviations flagged in the report):
- **Dedupe:** `UNIQUE NULLS NOT DISTINCT (user_id, player_key, stat, line,
side, game_id)` — PG17 native; PostgREST upsert can target it directly
(the coalesce-expression-index alternative can't be used by
`on_conflict`).
- **player_key** = `nameKey()` from `src/utils/playerName.js` (normalized
slug: lowercase, accent-folded, suffix/nickname-resolved) — this IS the
work-order 1.6 canonical key; league IDs can backfill later.
- **RLS:** enabled. anon+authenticated SELECT where `user_id IS NULL`
(public model record); authenticated SELECT own rows; NO client INSERT/
UPDATE/DELETE policies — all writes via service role.
## 2. Write paths
- **Pipeline (priority):** `ledgerService.recordPipelineGrades` called from
`snapshotService.runSnapshot` after the snapshot locks — upserts one row
per enriched grade, user_id null, `line`/`locked_odds` from `gradedAt`
(captured from the real odds feed at grade time), `book` from the odds
prop. Idempotent via the unique constraint (`ignoreDuplicates` upsert —
a re-run never duplicates and never overwrites the original lock).
game_id = odds event id when present, else `{sport}:{gameDateET}:{away}@{home}`.
- **User scans:** the Next `/api/scan` route (which already writes
scan_history) inserts a ledger row for AUTHENTICATED users only —
anonymous scans are NOT written (a null user_id would pollute the public
model record). Line/book from the scan body (the slate pre-fills real
book lines); locked_odds/game info enriched from the odds cache when the
prop matches.
- **Degenerate guard (work-order 1.5):** `analyzeViaEngine1` now returns
`insufficient_data: true` + `grade: null` when the model has NO projection
(no l5/l20 reference — the same reference edge_pct uses). Nothing writes
that to grades caches or the ledger; the scan UI renders
"INSUFFICIENT DATA — no read." The web gradeAdapter no longer displays
the line as the projection (the audit's model==line fabrication).
`projection` (real model number) is now attached to every graded result.
## 3. Settlement + CLV
- `ledgerService.captureClosing(sport, props)` runs on EVERY snapshot: for
today's unsettled public rows, overwrite closing_line/closing_odds with
the current real feed values. The last write before the game starts is
the closing line (after start the prop leaves the feed → stops updating).
- `ledgerService.settleLedger(sport)` runs in the scheduler's settle pass
(before grading, same as outcomeService): for unsettled rows with
game_date <= yesterday ET, resolve the actual stat via the same MLB
game-log source outcomeService uses; set outcome/actual_value/settled_at;
compute `clv` SIGNED BY SIDE (over: locked_line closing_line positive
when the market moved toward the over… i.e. closing under the locked
line means you beat the number) and `clv_result` beat/faded/flat.
Idempotent: only touches rows where outcome IS NULL / clv IS NULL.
- Manual trigger: POST /api/internal/ledger/settle (internal key).
## 4. /ledger population
- Backend: `GET /api/ledger/mine` (auth; sport/grade filters, newest first)
+ `GET /api/ledger/model` (public: user_id-null rows + 30d aggregate
{settled, hits, misses, pushes, hit_pct, clv_beat, clv_faded, clv_flat,
beat_close_pct, pending}). Next proxies for both.
- Frontend: /ledger gets MY READS | MODEL tabs. Model header shows
"hit% · beat close%" ONLY at n≥20 settles; below → "RECORD BUILDING —
every read settles here, misses included" + live pending count.
- `ModelRecord` shared component (deferred-render) mounted on landing
(proof strip footer) — player/team wiring rides the same endpoint later.
## 5. SYNC threshold
`SNAPSHOT_EXPECTED_INTERVAL` (seconds, env; default 18000 = the 5h max cron
gap). `/api/snapshot/summary` returns it as `expected_interval_s`.
HeartbeatBar: normal < 1.5x · amber ≥ 1.5x · red "STALE" ≥ 3x. When Phase
2.5 ships, drop the env value — no UI change needed.
## 6. Phase 2.5 logged
See `specs/vyndr-roadmap.md` — intraday odds-only refresh + directional
movement handling (STEAM/VALUE badges, auto re-grade on ≥1.0 move against,
public `revised_from_grade` revisions). Build after Phase 3 mobile.
## Test plan
Unit: ledgerService (record/idempotency/closing/settle/CLV signs/aggregate),
engine insufficient-data, gradeAdapter no-fabrication. Integration: ledger
routes (mine auth, model public), snapshot wiring. RLS verified live via SQL.
+46
View File
@@ -60,6 +60,52 @@ build-out target.
Scope key: S ≈ ½ session, M ≈ 1 session, L ≈ 12 sessions.
> NOTE (Session 58): the numbered plan above predates the overhaul work
> order. The work order's phases (0 = kill the lies ✅ S57, 1 = truth
> infrastructure ✅ S58, 2 = slate UX, 3 = mobile, **2.5 below**, 4 = scan/
> parlay, 5 = records, 6 = landing/content) take sequencing priority; the
> table's items slot in where they don't conflict.
## Phase 2.5 — Intraday line refresh + directional movement (LOGGED Session 58)
Authoritative scope from the Phase 1 GO prompt. **Build after Phase 3
(mobile), before Phase 4.** During slate hours (~noonmidnight ET):
- Lightweight ODDS-ONLY refresh every 1530 min (no full re-grade run).
Recompute the signed delta per graded prop RELATIVE TO THE GRADED SIDE —
direction is the signal, not magnitude alone.
- **Moved WITH the grade** (market chasing our number): PropRow badge
`STEAM ▲ +N`. Good for the record; entry edge compressed. No re-grade.
- **Moved AGAINST the grade ≥ 1.0** (or odds-equivalent): auto re-grade THAT
PROP ONLY.
- Grade holds → badge `VALUE ▲ better entry` (better number, same read).
- Grade drops → update the grade with `revised_from_grade` set (column
already exists in `ledger_entries`) + a visible revision marker
(original grade struck through — the ledger UI already renders it).
Revisions are ALWAYS public — never silently regrade, per Ledger ethos.
- All displayed lines remain real book values from the refresh — the
refresh CAPTURES market numbers, never computes them.
- After ship: drop `SNAPSHOT_EXPECTED_INTERVAL` to the refresh cadence —
the HeartbeatBar SYNC badge (normal <1.5x · amber ≥1.5x · STALE ≥3x)
goes genuinely live with zero further UI changes.
## Phase 4.5 — WNBA settlement via ESPN box scores (DUE DATE, not "roadmap")
Logged Session 59. Pending-forever WNBA ledger rows are honest but become a
credibility hole past ~2 weeks of accumulation. **Due: within 2 weeks of
day one of the public record (by ~Jul 24 2026).** Scope: ESPN box-score
settle path in outcomeService + ledgerService `defaultGetPlayerStats`
(WNBA branch) so WNBA rows settle nightly like MLB. Build right after
Phase 4 (scan/parlay polish).
## Phase 5 addition — calibration by grade tier (logged Session 59)
Once settles mature: the MODEL tab shows A-tier hit% vs B-tier hit%
SEPARATELY (same n≥20 rule PER TIER — a tier below threshold shows
"building", never a %). The separation between tiers is the proof the
grades mean something. `ledger_entries.grade` already carries the tier;
this is an aggregate + UI change only.
---
## Stat-type coverage target (fully built)
+17
View File
@@ -180,6 +180,23 @@ router.post('/outcomes/all', async (req, res) => {
}
});
/**
* POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the
* persistent ledger (outcome + actual_value + CLV) across every sport.
* Idempotent — safe to re-run; already-settled rows are never touched.
*/
router.post('/ledger/settle', async (req, res) => {
const ledger = require('../services/ledgerService');
try {
const results = await ledger.settleAllLedgers();
return res.json({ ok: true, results });
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/ledger/settle] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
router.post('/outcomes/:sport', async (req, res) => {
const outcomes = require('../services/outcomeService');
try {
+83 -4
View File
@@ -1,20 +1,45 @@
'use strict';
/**
* GET /api/ledger/accuracy (Session 55) — grade-tier buckets for the ledger UI.
* /api/ledger — the truth surface (Sessions 55 + 58).
*
* The Next proxy `web/src/app/api/ledger/accuracy` has expected a `{ buckets }`
* shape since before a writer existed; the self-learning loop now fills it.
* Public, cache-only (reads outcomeService's persisted accuracy record).
* GET /accuracy — grade-tier buckets (Session 55, snapshot-log based).
* GET /mine — the caller's own ledger_entries, newest first (Phase 1).
* GET /model — the PUBLIC model record (user_id NULL rows) + the 30d
* aggregate {hit_pct, beat_close_pct, pending}. Percentages
* are null below MIN_AGG_SAMPLE (20) — the UI renders
* "RECORD BUILDING" instead. Never a % on a small n.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const { requireAuth } = require('../middleware/auth');
const outcomeService = require('../services/outcomeService');
const ledgerService = require('../services/ledgerService');
const { nameKey } = require('../utils/playerName');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
const VALID_SPORTS = new Set(['nba', 'mlb', 'wnba', 'soccer']);
const VALID_TIERS = new Set(['A', 'B', 'C', 'D', 'F']);
const ROW_COLUMNS = 'id, player_key, player_name, sport, stat, line, side, locked_odds, book, grade, edge, confidence, model_value, graded_at, game_id, game_date, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at, revised_from_grade';
function sbOrNull() {
try {
if (!ledgerService.__internals.isConfigured()) return null;
return require('../utils/supabase').getSupabaseServiceClient();
} catch { return null; }
}
function applyFilters(query, req) {
const sport = String(req.query.sport || '').toLowerCase();
const tier = String(req.query.tier || req.query.grade || '').toUpperCase();
if (VALID_SPORTS.has(sport)) query = query.eq('sport', sport);
if (VALID_TIERS.has(tier)) query = query.ilike('grade', `${tier}%`);
return query;
}
router.get('/accuracy', async (req, res) => {
try {
const acc = await outcomeService.getAccuracy();
@@ -27,4 +52,58 @@ router.get('/accuracy', async (req, res) => {
}
});
// The caller's own reads. Service-role read scoped to req.user.id — clients
// never query the table directly (RLS blocks writes; reads go through here).
router.get('/mine', requireAuth, async (req, res) => {
const sb = sbOrNull();
if (!sb) return res.json({ entries: [] });
try {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 100));
let q = sb.from('ledger_entries')
.select(ROW_COLUMNS)
.eq('user_id', req.user.id);
q = applyFilters(q, req);
const { data, error } = await q
.order('graded_at', { ascending: false })
.limit(limit);
if (error) throw new Error(error.message);
return res.json({ entries: data || [] });
} catch (err) {
console.error('[ledger/mine]', err.message);
return res.status(200).json({ entries: [] });
}
});
// The public model record: every pipeline pre-grade, settled or pending,
// misses included. This is the product's proof surface.
router.get('/model', async (req, res) => {
const sb = sbOrNull();
try {
const aggregate = await ledgerService.getModelAggregate({
sport: VALID_SPORTS.has(String(req.query.sport || '').toLowerCase())
? String(req.query.sport).toLowerCase() : undefined,
// VYNDR-on-player (work-order 1.4/5.2): per-player public record.
playerKey: req.query.player ? nameKey(String(req.query.player).slice(0, 60)) : undefined,
});
let entries = [];
if (sb) {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 60));
let q = sb.from('ledger_entries')
.select(ROW_COLUMNS)
.is('user_id', null);
q = applyFilters(q, req);
const { data, error } = await q
.order('graded_at', { ascending: false })
.limit(limit);
if (error) throw new Error(error.message);
entries = data || [];
}
res.set('Cache-Control', 'public, max-age=60');
return res.json({ entries, aggregate, min_sample: ledgerService.MIN_AGG_SAMPLE });
} catch (err) {
console.error('[ledger/model]', err.message);
return res.status(200).json({ entries: [], aggregate: null, min_sample: ledgerService.MIN_AGG_SAMPLE });
}
});
module.exports = router;
+40
View File
@@ -36,6 +36,46 @@ 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;
// Session 58 (Task 5) — the SYNC badge thresholds key off the pipeline's
// EXPECTED cadence, not a flat 5 minutes: normal < 1.5x, amber ≥ 1.5x,
// STALE red ≥ 3x. Default = the 5h max cron gap; when Phase 2.5's
// intraday refresh ships, drop the env value and the badge goes live
// with zero UI changes.
const expected_interval_s = Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) > 0
? Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) : 18000;
res.set('Cache-Control', 'public, max-age=30');
return res.json({ graded, updated_at, sports, expected_interval_s });
} 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 {
+28 -10
View File
@@ -129,22 +129,40 @@ async function getBatterVsPitcher(batterId, pitcherId, group = 'hitting') {
return splits.length > 0 ? (splits[0].stat || null) : null;
}
const normName = (n) => String(n == null ? '' : n).toLowerCase().replace(/[^a-z0-9]/g, '');
const { nameKey } = require('../../utils/playerName');
/**
* Resolve a player name → MLB person record (Session 43). Pulls the season
* player list (cached 24h — heavy but rarely changes) and matches by
* normalized full name. Returns { id, fullName, team, teamId, position } or
* null. Needed because every other adapter method keys on playerId.
* Resolve a player name → MLB person record (Session 43; rebuilt Session 59,
* work-order 1.6). Pulls the season player list (cached 24h) and matches on
* the CANONICAL nameKey — accent-folded, suffix/nickname-resolved — so
* "Sánchez" ≡ "Sanchez" and "Matt" ≡ "Matthew" resolve to ONE player.
*
* The old matcher lower-cased and stripped non-[a-z0-9], which DELETED
* accented letters ("Sánchez" → "snchez" ≠ "sanchez") and then fell back to
* a raw substring match that could silently return the WRONG player — the
* audit's "last-10 opponents don't match his team" bug. The fallback now
* requires a UNIQUE same-last-name + same-first-initial candidate; anything
* ambiguous returns null (a missing profile beats another player's log).
*/
async function searchPlayer(name, season = DEFAULT_SEASON) {
const target = normName(name);
if (!target) return null;
const targetKey = nameKey(name);
if (!targetKey) return null;
const url = `${BASE}/sports/1/players?season=${season}`;
const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
const people = (data && Array.isArray(data.people)) ? data.people : [];
const hit = people.find((p) => normName(p.fullName) === target)
|| people.find((p) => normName(p.fullName).includes(target) && target.length >= 6);
let hit = people.find((p) => nameKey(p.fullName) === targetKey);
if (!hit) {
const parts = targetKey.split(' ');
const first = parts[0] || '';
const last = parts[parts.length - 1] || '';
if (first && last && first !== last) {
const cands = people.filter((p) => {
const k = nameKey(p.fullName).split(' ');
return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
});
if (cands.length === 1) hit = cands[0]; // unique or nothing — never guess
}
}
if (!hit) return null;
return {
id: hit.id,
@@ -234,5 +252,5 @@ module.exports = {
getTeams,
resolveTeam,
getTeamRoster,
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, normName },
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON },
};
+4 -1
View File
@@ -64,7 +64,10 @@ async function gradeBestSide(grade, prop, sport) {
.then(() => grade({ ...base, direction: 'under' }))
.catch(() => null),
]);
const cands = sides.filter(Boolean);
// Session 58 (work-order 1.5) — a refused read (insufficient_data /
// no grade) never enters the graded slate: no hollow rows in the grades
// cache, the snapshot, or the ledger.
const cands = sides.filter((s) => s && s.grade && !s.insufficient_data);
if (cands.length === 0) return null;
return cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
}
+47 -17
View File
@@ -232,34 +232,52 @@ function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, pr
};
}
// edge_pct in the legacy shape compares the relevant average to the line.
// We use l5_avg when present (matches legacy "recent form" weighting),
// fall back to l20_avg, otherwise return 0 so the field is always present.
// The model's projection: the same reference the edge is computed from.
// Recent-form averages first (l5, else l20); soccer props (which carry
// per-90 rates instead of game averages) fall back to `{stat}_per_90`,
// then xG for goals. This is a REAL model number — it is never the line.
// When it's null the model has no projection and the read must refuse
// (insufficient_data), not ship a hollow grade.
function projectionFor(features, prop) {
const f = features || {};
const round2 = (n) => Math.round(n * 100) / 100;
if (Number.isFinite(f.l5_avg)) return round2(f.l5_avg);
if (Number.isFinite(f.l20_avg)) return round2(f.l20_avg);
const stat = String(prop?.stat_type || '').toLowerCase();
const per90 = f[`${stat}_per_90`];
if (Number.isFinite(per90)) return round2(per90);
if (stat === 'goals' && Number.isFinite(f.xg_per_90)) return round2(f.xg_per_90);
return null;
}
// edge_pct in the legacy shape compares the model projection to the line.
function edgePctFor(features, prop) {
const ref = Number.isFinite(features?.l5_avg) ? features.l5_avg
: Number.isFinite(features?.l20_avg) ? features.l20_avg
: null;
const ref = projectionFor(features, prop);
if (ref == null || !Number.isFinite(prop?.line) || prop.line === 0) return 0;
const signed = prop.direction === 'over' ? (ref - prop.line) : (prop.line - ref);
return Math.round((signed / prop.line) * 1000) / 10;
}
// When computeFeatures fails so badly that even a partial feature vector
// is empty, return a legacy-shaped low-confidence result rather than
// asking engine1 to grade nothing.
function fallbackLegacyResult(rawProp, errors) {
// Session 58 (work-order 1.5) — when the model has NO projection there is no
// read. This used to return grade 'C' / confidence 10 / edge 0 — the exact
// "hollow C" the live audit caught (model==line, +0% edge). A refused read
// builds more trust than a fake one: grade is null, insufficient_data is
// true, and NOTHING downstream (grades cache, snapshot, ledger) persists it.
function insufficientDataResult(rawProp, errors) {
return {
player: rawProp.player ?? null,
stat_type: rawProp.stat_type ?? null,
line: rawProp.line ?? null,
direction: rawProp.direction ?? null,
book: rawProp.book || 'unknown',
grade: 'C',
confidence: 10,
grade: null,
insufficient_data: true,
confidence: 0,
edge_pct: 0,
projection: null,
kill_conditions_triggered: [],
reasoning: {
summary: `Unable to compute full analysis. ${explainErrors(errors) || ''} Grade is provisional.`.trim(),
summary: `INSUFFICIENT DATA — no read. ${explainErrors(errors) || 'The model has no projection for this prop.'}`.trim(),
steps: [],
},
};
@@ -334,12 +352,19 @@ async function analyzeViaEngine1(rawProp = {}) {
const featureResult = await computeFeaturesForProp(rawProp);
const { features, trap, consistency, prop, meta } = featureResult;
// Hard fallback only when computeFeatures couldn't produce anything
// useful at all (no features AND no consistency input).
// Hard refusal when computeFeatures couldn't produce anything useful at
// all (no features AND no consistency input) — there is nothing to grade.
if ((!features || Object.keys(features).length === 0)
&& (!consistency || consistency.consistency === 'unknown')
&& (!Array.isArray(meta?.gameLogs) || meta.gameLogs.length === 0)) {
return fallbackLegacyResult(rawProp, meta?.errors);
return insufficientDataResult(rawProp, meta?.errors);
}
// Session 58 (work-order 1.5) — no projection ⇒ no read. Without a model
// reference the edge is fictional and the grade would be hollow.
const projection = projectionFor(features, { ...rawProp, line: prop.line });
if (projection == null) {
return insufficientDataResult(rawProp, meta?.errors);
}
// Engine 1: deterministic rule-based grade on the feature vector.
@@ -377,6 +402,10 @@ async function analyzeViaEngine1(rawProp = {}) {
// intelligence). Optional + self-hiding on the card; zero extra I/O.
Object.assign(legacy, buildIntelFields(features));
// Session 58 — the model's REAL projection (never the line). Persisted as
// ledger model_value and rendered under the MODEL label on the card.
legacy.projection = projection;
return legacy;
}
@@ -385,7 +414,8 @@ module.exports = {
__internals: {
buildConcreteReasoning,
edgePctFor,
fallbackLegacyResult,
projectionFor,
insufficientDataResult,
explainErrors,
ERROR_EXPLANATIONS,
buildIntelFields,
+410
View File
@@ -0,0 +1,410 @@
'use strict';
/**
* ledgerService — the truth infrastructure (Session 58, work-order Phase 1).
*
* Persists every grade to Supabase `ledger_entries`:
* - Pipeline pre-grades (user_id NULL) — the PUBLIC model record. Written by
* snapshotService after each snapshot locks. This is the priority path:
* hundreds of settles per night vs user scans trickling in.
* - User scans are written by the web /api/scan route (it owns the user
* identity); this service owns settlement + closing capture for BOTH.
*
* DATA SEMANTICS: `line` / `locked_odds` / `book` / `closing_line` /
* `closing_odds` are REAL book values captured at grade / refresh time —
* never computed. `model_value` is VYNDR's projection. A grade with no
* projection (insufficient_data) is never written — no hollow rows.
*
* Closing-line value: captureClosing runs on EVERY snapshot and overwrites
* closing_line/closing_odds for today's unsettled rows with the CURRENT feed
* values — the last write before the game starts is the closing line (once a
* game starts its props leave the feed, so updates stop naturally).
* settleLedger then computes `clv` SIGNED BY SIDE: for an OVER, a closing
* line BELOW the locked line = the market moved toward the graded side =
* positive = 'beat'. For an UNDER, the inverse.
*
* Everything is injectable; without SUPABASE env the service no-ops
* gracefully (tests / local dev without a database).
*/
const { nameKey, normalizeName } = require('../utils/playerName');
const { settleResult, statValue } = require('./outcomeService');
const CONFLICT = 'user_id,player_key,stat,line,side,game_id';
const UPSERT_CHUNK = 200;
const SETTLE_FETCH_LIMIT = 500;
const AGG_WINDOW_DAYS = 30;
const AGG_FETCH_LIMIT = 5000;
/** Below this many settled rows, callers must not render a percentage. */
const MIN_AGG_SAMPLE = 20;
function isConfigured() {
return Boolean(process.env.SUPABASE_URL
&& (process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY));
}
function defaultClient() {
return require('../utils/supabase').getSupabaseServiceClient();
}
/** ET calendar date (YYYY-MM-DD) of an ISO timestamp; null when unparseable. */
function dateET(iso) {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
const todayET = () => dateET(new Date().toISOString());
/** Derived game id when the feed carries no event id: sport:date:AWAY@HOME. */
function gameIdFor(sport, prop, gameDate) {
const away = String((prop && prop.away_team) || 'UNK').replace(/\s+/g, '');
const home = String((prop && prop.home_team) || 'UNK').replace(/\s+/g, '');
return `${sport}:${gameDate}:${away}@${home}`;
}
const sideOf = (direction) =>
(String(direction || 'over').toLowerCase() === 'under' ? 'under' : 'over');
// Strict numeric parse: null/undefined stay null (Number(null) is 0 — a
// fabricated zero line/odds is exactly what the data-semantics rule forbids).
const numOrNull = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));
// Nickname token (last word, lowercased) — the stable cross-source team
// identifier ("New York Yankees" ↔ "Yankees" ↔ "NYY" won't match, but
// full-name feeds match full-name feeds; abbr feeds match abbr feeds).
const nickToken = (name) => {
const w = String(name || '').trim().split(/\s+/);
return (w[w.length - 1] || '').toLowerCase().replace(/[^a-z]/g, '');
};
const teamsMatch = (a, b) => {
if (!a || !b) return false;
const sa = String(a).toLowerCase(), sb = String(b).toLowerCase();
return sa === sb || nickToken(a) === nickToken(b);
};
/**
* Session 59 — team/opponent from the REAL feed. The player's team comes
* from the stats resolve (g.team); the opponent is the other side of the
* prop's game IF the team matches one of its participants. No match →
* opponent stays null — never guessed.
*/
function teamOpponentFor(g, prop) {
const team = g && g.team ? String(g.team) : null;
if (!team || !prop) return { team, opponent: null };
if (teamsMatch(team, prop.home_team)) return { team, opponent: prop.away_team || null };
if (teamsMatch(team, prop.away_team)) return { team, opponent: prop.home_team || null };
return { team, opponent: null };
}
/** Index odds props by nameKey|stat for lock/closing lookups. */
function indexProps(props) {
const map = {};
for (const p of props || []) {
if (!p || !p.player || !p.stat_type) continue;
const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
if (!map[k]) map[k] = p;
}
return map;
}
function oddsForSide(prop, side) {
if (!prop) return null;
const v = side === 'under'
? (prop.under_odds ?? prop.under ?? null)
: (prop.over_odds ?? prop.over ?? null);
return v == null ? null : String(v);
}
/**
* Build ledger rows from a snapshot's enriched grades + the raw odds props.
* Skips anything without a real grade or without a captured line — the
* ledger never holds a fabricated market value or a refused read.
*/
function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
const sp = String(sport || '').toLowerCase();
const byKey = indexProps(oddsProps);
const rows = [];
for (const g of grades || []) {
if (!g || !g.grade || g.insufficient_data) continue;
const player = g.player || g.player_name;
if (!player) continue;
const stat = String(g.stat_type || g.stat || '').toLowerCase();
if (!stat) continue;
const side = sideOf(g.direction);
const locked = g.gradedAt || {};
const line = numOrNull(locked.line) ?? numOrNull(g.line);
if (line == null) continue; // no real captured line → no row
const prop = byKey[`${nameKey(player)}|${stat}`] || null;
const gradedTs = locked.timestamp || nowIso;
const gameDate = dateET(prop && prop.game_time) || dateET(gradedTs) || todayET();
const { team, opponent } = teamOpponentFor(g, prop);
rows.push({
team,
opponent,
user_id: null,
player_key: nameKey(player),
player_name: normalizeName(player).display || player,
sport: sp,
stat,
line,
side,
locked_odds: locked.odds != null ? String(locked.odds) : oddsForSide(prop, side),
book: (prop && prop.book) || g.book || null,
grade: g.grade,
edge: numOrNull(g.edge_pct),
confidence: numOrNull(g.confidence),
model_value: numOrNull(g.projection),
graded_at: gradedTs,
game_id: gameIdFor(sp, prop, gameDate),
game_date: gameDate,
});
}
return rows;
}
/**
* Upsert the pipeline's pre-grades (user_id NULL — the public model record).
* Idempotent: re-runs hit the dedupe constraint and are IGNORED, so the
* original locked line/odds are never overwritten by a later run.
*/
async function recordPipelineGrades(sport, grades, oddsProps, opts = {}) {
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', written: 0 };
const sb = opts.sb || defaultClient();
const nowIso = (opts.now || (() => new Date().toISOString()))();
const rows = rowsFromSnapshot(sport, grades, oddsProps, nowIso);
if (rows.length === 0) return { written: 0 };
let written = 0;
for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
const chunk = rows.slice(i, i + UPSERT_CHUNK);
const { error } = await sb.from('ledger_entries')
.upsert(chunk, { onConflict: CONFLICT, ignoreDuplicates: true });
if (error) return { written, error: error.message };
written += chunk.length;
}
return { written };
}
/**
* Overwrite closing_line/closing_odds on today's UNSETTLED rows from the
* current (real) odds feed. Runs on every snapshot; the last capture before
* game start is the closing line. Matches by player_key+stat — the closing
* line may legitimately differ from the locked line (that's CLV).
*/
async function captureClosing(sport, oddsProps, opts = {}) {
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', updated: 0 };
const sb = opts.sb || defaultClient();
const sp = String(sport || '').toLowerCase();
const gameDate = opts.gameDate || todayET();
const byKey = indexProps(oddsProps);
if (Object.keys(byKey).length === 0) return { updated: 0 };
const { data: open, error } = await sb.from('ledger_entries')
.select('id, player_key, stat, side')
.eq('sport', sp)
.eq('game_date', gameDate)
.is('outcome', null)
.limit(SETTLE_FETCH_LIMIT);
if (error) return { updated: 0, error: error.message };
let updated = 0;
// Group row ids by identical closing values → one UPDATE per prop.
const groups = new Map();
for (const row of open || []) {
const prop = byKey[`${row.player_key}|${row.stat}`];
const closingLine = prop ? numOrNull(prop.line) : null;
if (closingLine == null) continue;
const closingOdds = oddsForSide(prop, row.side);
const gk = `${closingLine}|${closingOdds ?? ''}`;
if (!groups.has(gk)) groups.set(gk, { line: closingLine, odds: closingOdds, ids: [] });
groups.get(gk).ids.push(row.id);
}
for (const g of groups.values()) {
const { error: upErr } = await sb.from('ledger_entries')
.update({ closing_line: g.line, closing_odds: g.odds })
.in('id', g.ids);
if (!upErr) updated += g.ids.length;
}
return { updated };
}
/**
* Signed CLV per the Phase 1 amendment: positive = the market moved TOWARD
* the graded side. OVER: locked closing (closing dropped ⇒ positive).
* UNDER: closing locked.
*/
function computeClv(side, lockedLine, closingLine) {
const locked = numOrNull(lockedLine);
const closing = numOrNull(closingLine);
if (locked == null || closing == null) return null;
const raw = sideOf(side) === 'over' ? locked - closing : closing - locked;
return Math.round(raw * 100) / 100;
}
function clvResultOf(clv) {
if (clv == null) return null;
if (clv > 0) return 'beat';
if (clv < 0) return 'faded';
return 'flat';
}
/**
* Settle unsettled ledger rows with game_date <= yesterday against the real
* stat result (same free MLB game-log source outcomeService uses; other
* sports stay pending until they have a settled-result feed). Also computes
* CLV from the captured closing line. Idempotent: only rows with
* outcome IS NULL are fetched, and a row is written at most once.
*/
async function settleLedger(sport, opts = {}) {
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', settled: 0, pending: 0 };
const sb = opts.sb || defaultClient();
const sp = String(sport || '').toLowerCase();
const nowIso = (opts.now || (() => new Date().toISOString()))();
const getPlayerStats = opts.getPlayerStats || defaultGetPlayerStats;
const cutoff = opts.beforeDate || todayET(); // settle strictly-before today
const { data: open, error } = await sb.from('ledger_entries')
.select('id, player_key, player_name, stat, line, side, closing_line')
.eq('sport', sp)
.is('outcome', null)
.lt('game_date', cutoff)
.order('game_date', { ascending: true })
.limit(SETTLE_FETCH_LIMIT);
if (error) return { settled: 0, pending: 0, error: error.message };
if (!open || open.length === 0) return { settled: 0, pending: 0 };
// We need each row's game_date for the log match — refetch with it included.
const { data: rows } = await sb.from('ledger_entries')
.select('id, player_name, stat, line, side, closing_line, game_date')
.in('id', open.map((r) => r.id));
// One game-log fetch per unique player.
const players = [...new Set((rows || []).map((r) => r.player_name))];
const logByPlayer = {};
for (const player of players) {
try {
const stats = await getPlayerStats(player, sp);
logByPlayer[player] = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : [];
} catch { logByPlayer[player] = []; }
}
let settled = 0;
let pending = 0;
for (const row of rows || []) {
const log = logByPlayer[row.player_name] || [];
const gameRow = log.find((r) => r && r.date === row.game_date);
if (!gameRow) { pending += 1; continue; }
const actual = statValue(gameRow.stat, row.stat);
if (actual == null) { pending += 1; continue; }
const outcome = settleResult(row.side, actual, row.line);
if (!outcome) { pending += 1; continue; }
const clv = computeClv(row.side, row.line, row.closing_line);
const { error: upErr } = await sb.from('ledger_entries')
.update({
outcome,
actual_value: actual,
settled_at: nowIso,
clv,
clv_result: clvResultOf(clv),
})
.eq('id', row.id)
.is('outcome', null); // double-settle guard even across concurrent runs
if (upErr) { pending += 1; continue; }
settled += 1;
}
return { settled, pending };
}
async function settleAllLedgers(opts = {}) {
const sports = opts.sports || ['mlb', 'nba', 'wnba', 'soccer'];
const results = [];
for (const sp of sports) {
try { results.push({ sport: sp, ...(await settleLedger(sp, opts)) }); }
catch (e) { results.push({ sport: sp, settled: 0, pending: 0, error: e.message }); }
}
return results;
}
async function defaultGetPlayerStats(name, sport) {
if (String(sport).toLowerCase() === 'mlb') {
return require('./adapters/mlbStatsAdapter').getPlayerStats(name);
}
return { found: false }; // no free settled-result feed yet → pending
}
/**
* 30-day aggregate over the PUBLIC model record (user_id NULL): hit rate,
* beat-the-close rate, pending count. Percentages are null below
* MIN_AGG_SAMPLE — the UI must show "record building" instead.
*/
async function getModelAggregate(opts = {}) {
const empty = {
window_days: AGG_WINDOW_DAYS, min_sample: MIN_AGG_SAMPLE,
settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null,
clv_sample: 0, clv_beat: 0, clv_faded: 0, clv_flat: 0, beat_close_pct: null,
pending: 0,
};
if (!opts.sb && !isConfigured()) return empty;
const sb = opts.sb || defaultClient();
const nowMs = (opts.nowMs || (() => Date.now()))();
const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
let settledQ = sb.from('ledger_entries')
.select('outcome, clv_result, player_key')
.is('user_id', null)
.not('outcome', 'is', null)
.gte('game_date', since)
.limit(AGG_FETCH_LIMIT);
if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase());
if (opts.playerKey) settledQ = settledQ.eq('player_key', opts.playerKey);
const { data: settledRows, error } = await settledQ;
if (error) return { ...empty, error: error.message };
let pendingQ = sb.from('ledger_entries')
.select('id', { count: 'exact', head: true })
.is('user_id', null)
.is('outcome', null);
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
const { count: pending } = await pendingQ;
const agg = { ...empty, pending: pending || 0 };
for (const r of settledRows || []) {
agg.settled += 1;
if (r.outcome === 'hit') agg.hits += 1;
else if (r.outcome === 'miss') agg.misses += 1;
else if (r.outcome === 'push') agg.pushes += 1;
if (r.clv_result) {
agg.clv_sample += 1;
if (r.clv_result === 'beat') agg.clv_beat += 1;
else if (r.clv_result === 'faded') agg.clv_faded += 1;
else agg.clv_flat += 1;
}
}
const decided = agg.hits + agg.misses;
// n<20 → null: never render a percentage on a small sample.
if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) {
agg.hit_pct = Math.round((agg.hits / decided) * 100);
}
if (agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) {
agg.beat_close_pct = Math.round((agg.clv_beat / agg.clv_sample) * 100);
}
return agg;
}
module.exports = {
recordPipelineGrades,
captureClosing,
settleLedger,
settleAllLedgers,
getModelAggregate,
MIN_AGG_SAMPLE,
__internals: {
rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor,
dateET, sideOf, oddsForSide, isConfigured, CONFLICT,
teamOpponentFor, teamsMatch,
},
};
+4
View File
@@ -292,6 +292,10 @@ module.exports = {
getAccuracy,
computeAccuracy,
accuracyBuckets,
// Session 58 — settlement primitives shared with ledgerService (single
// source of truth for hit/miss/push + MLB log-field resolution).
settleResult,
statValue,
SPORTS,
MIN_SAMPLE,
__internals: { settleResult, gradeBucket, dateStrings, statValue, outcomeKey, MLB_LOG_FIELD, TIERS },
+14 -1
View File
@@ -34,7 +34,20 @@ async function scanParlay(user, legs) {
// crashing the whole parlay.
const settled = await Promise.allSettled(legs.map((leg) => analyzeViaEngine1(leg)));
const legResults = settled.map((s, i) => {
if (s.status === 'fulfilled') return s.value;
if (s.status === 'fulfilled') {
// Session 58 (work-order 1.5) — a refused read carries grade null;
// the parlay flow needs a letter per leg, so it takes the existing
// failed-leg convention (F / 0 confidence) with an honest summary.
if (s.value && s.value.insufficient_data) {
return {
...s.value,
grade: 'F',
insufficient_data: true,
reasoning: { summary: 'INSUFFICIENT DATA — no read for this leg.' },
};
}
return s.value;
}
return {
...legs[i],
error: s.reason?.message || 'analysis_failed',
+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);
+29 -2
View File
@@ -18,7 +18,6 @@
*/
const SNAP_TTL = 6 * 3600; // 6h — a snapshot is valid until the next run
const GRADES_TTL = 2 * 3600; // matches gradeSlateService
const TICKER_TTL = 24 * 3600;
const TICKER_CAP = 50;
const DELTA_NOISE = 0.5; // ignore movements smaller than this
@@ -188,6 +187,9 @@ async function runSnapshot(sport, opts = {}) {
notify: opts.notify || require('../utils/opsNotify').notify,
sleep: opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))),
retryDelayMs: opts.retryDelayMs != null ? opts.retryDelayMs : 60_000,
// Session 58 — Phase 1 truth infrastructure. ledger no-ops without
// SUPABASE env, so tests / local dev never touch a database.
ledger: opts.ledger || require('./ledgerService'),
};
const start = deps.nowMs();
const ts = deps.now();
@@ -261,12 +263,17 @@ async function runSnapshot(sport, opts = {}) {
const oddsByKey = indexOdds(props);
const players = [...new Set(graded.map((g) => g.player || g.player_name).filter(Boolean))];
const archByPlayer = {};
// Session 59 — capture the player's REAL team from the same stats resolve
// (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns
// and the slate join guard (a prop only attaches to its own game).
const teamByPlayer = {};
await mapLimit(players, STATS_CONCURRENCY, async (player) => {
try {
const stats = await deps.resolveStats(player, sp);
if (stats && stats.found) {
const c = deps.classify(sp, stats.classifierInput || {});
archByPlayer[player] = c.primary ? c.primary.name : null;
if (stats.team) teamByPlayer[player] = stats.team;
}
} catch { /* graceful — no badge */ }
});
@@ -275,6 +282,7 @@ async function runSnapshot(sport, opts = {}) {
...g,
gradedAt: gradedAtFor(g, oddsByKey, ts),
archetype: archByPlayer[g.player || g.player_name] || null,
team: teamByPlayer[g.player || g.player_name] || g.team || null,
}));
// Line deltas vs the previous snapshot's locked lines.
@@ -285,12 +293,30 @@ async function runSnapshot(sport, opts = {}) {
if (prev) await deps.cacheSet(`snapshot:${sp}:previous`, prev, SNAP_TTL);
const snapshot = { sport: sp, updated_at: ts, grades: enriched, deltas, gradeCount: enriched.length };
await deps.cacheSet(`snapshot:${sp}:latest`, snapshot, SNAP_TTL);
await deps.cacheSet(`grades:${sp}`, { grades: enriched, updated_at: ts, source: (odds && odds.provider) || 'odds-api' }, GRADES_TTL);
// Session 59 — grades:{sport} must outlive the gap between cron runs (up to
// 5h) or team rosters / Explore / leaders go dark mid-day. SNAP_TTL (6h),
// NOT the legacy 2h gradeSlateService TTL — that gap was why /team showed
// "No active props" for players who were on the slate (audit 2.4).
await deps.cacheSet(`grades:${sp}`, { grades: enriched, updated_at: ts, source: (odds && odds.provider) || 'odds-api' }, SNAP_TTL);
// Ticker exhaust.
const events = generateTickerEvents(sp, enriched, deltas, ts);
await pushTickerItems(events, deps);
// Session 58 — Phase 1 truth infrastructure. (a) Upsert the public model
// record (user_id NULL, idempotent — re-runs never duplicate or overwrite
// the original lock). (b) Overwrite today's closing_line/odds from the
// CURRENT feed — the last capture before game start IS the closing line.
// Best-effort: the ledger must never break the snapshot.
let ledgerWritten = 0;
try {
const rec = await deps.ledger.recordPipelineGrades(sp, enriched, props, { now: deps.now });
ledgerWritten = rec.written || 0;
await deps.ledger.captureClosing(sp, props);
} catch (e) {
console.warn(`[snapshot] ledger write failed for ${sp}:`, e.message);
}
// Session 56 — success alert, enriched with the rolling accuracy (if settled).
let accPart = '';
try {
@@ -307,6 +333,7 @@ async function runSnapshot(sport, opts = {}) {
sport: sp,
status: 'ok',
gradeCount: enriched.length,
ledgerWritten,
topGrades: enriched.filter((g) => isTopGrade(g.grade)).slice(0, 5).map((g) => ({
player: g.player || g.player_name, stat: g.stat_type || g.stat, grade: g.grade, archetype: g.archetype,
})),
+10
View File
@@ -55,6 +55,9 @@ function startSnapshotScheduler(opts = {}) {
// real (now-completed) results BEFORE grading the fresh slate, so the accuracy
// record reflects yesterday's games each cycle.
const settleAll = opts.settleAllOutcomes || require('./services/outcomeService').settleAllOutcomes;
// Session 58 — Phase 1 truth infrastructure: settle the persistent ledger
// (outcome + actual + CLV) in the same pre-grade settle pass.
const settleLedgers = opts.settleAllLedgers || require('./services/ledgerService').settleAllLedgers;
const notify = opts.notify || require('./utils/opsNotify').notify;
const cacheGet = opts.cacheGet || require('./utils/redis').cacheGet;
const now = opts.now || (() => new Date());
@@ -96,6 +99,13 @@ function startSnapshotScheduler(opts = {}) {
} catch (e) {
console.warn('[outcomes] settle run failed:', e.message);
}
try {
const ledger = await settleLedgers();
const n = ledger.reduce((t, r) => t + (r.settled || 0), 0);
console.log(`[ledger] settle pass — ${n} entries settled (outcome + CLV)`);
} catch (e) {
console.warn('[ledger] settle run failed:', e.message);
}
try {
const results = await runAll();
const ok = results.filter((r) => r.status === 'ok');
@@ -0,0 +1,78 @@
-- ---------------------------------------------------------------
-- 019 — ledger_entries (Session 58, work-order Phase 1 + CLV amendments).
--
-- The truth infrastructure: every grade — user scan AND pipeline pre-grade —
-- persists here, settles against the real result, and carries closing-line
-- value. Rows with user_id NULL are the PUBLIC model record.
--
-- DATA SEMANTICS: `line`, `locked_odds`, `book`, `closing_line`,
-- `closing_odds` are REAL book values captured at a timestamp — never
-- generated. `model_value` / grade / edge / confidence are VYNDR model
-- output. A row must never exist with a fabricated market value.
--
-- All writes go through the service role (pipeline + API). Clients only
-- read: their own rows, plus the public (user_id IS NULL) record.
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.ledger_entries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NULL REFERENCES auth.users (id) ON DELETE CASCADE,
player_key text NOT NULL,
player_name text NOT NULL,
sport text NOT NULL,
stat text NOT NULL,
line numeric NOT NULL,
side text NOT NULL CHECK (side IN ('over', 'under')),
locked_odds text NULL,
book text NULL,
grade text NOT NULL,
edge numeric NULL,
confidence numeric NULL,
model_value numeric NULL,
graded_at timestamptz NOT NULL DEFAULT now(),
game_id text NOT NULL,
game_date date NOT NULL,
closing_line numeric NULL,
closing_odds text NULL,
clv numeric NULL,
clv_result text NULL CHECK (clv_result IN ('beat', 'faded', 'flat')),
outcome text NULL CHECK (outcome IN ('hit', 'miss', 'push')),
actual_value numeric NULL,
settled_at timestamptz NULL,
revised_from_grade text NULL
);
-- Idempotency: pipeline re-runs and double-taps upsert into this. PG15+
-- NULLS NOT DISTINCT makes the null-user (public record) rows dedupe too,
-- and — unlike a coalesce-expression index — PostgREST `on_conflict` can
-- target a real constraint's column list.
ALTER TABLE public.ledger_entries
ADD CONSTRAINT ledger_entries_dedupe
UNIQUE NULLS NOT DISTINCT (user_id, player_key, stat, line, side, game_id);
CREATE INDEX IF NOT EXISTS idx_ledger_user_graded
ON public.ledger_entries (user_id, graded_at DESC);
CREATE INDEX IF NOT EXISTS idx_ledger_gamedate_outcome
ON public.ledger_entries (game_date, outcome);
CREATE INDEX IF NOT EXISTS idx_ledger_player_stat
ON public.ledger_entries (player_key, stat);
CREATE INDEX IF NOT EXISTS idx_ledger_public_clv
ON public.ledger_entries (clv_result) WHERE user_id IS NULL;
-- The settlement worker's scan: unsettled rows by date.
CREATE INDEX IF NOT EXISTS idx_ledger_unsettled
ON public.ledger_entries (game_date) WHERE outcome IS NULL;
ALTER TABLE public.ledger_entries ENABLE ROW LEVEL SECURITY;
-- Users read their own rows.
CREATE POLICY ledger_select_own ON public.ledger_entries
FOR SELECT TO authenticated
USING (user_id = auth.uid());
-- Everyone (anon + authenticated) reads the public model record.
CREATE POLICY ledger_select_public ON public.ledger_entries
FOR SELECT TO anon, authenticated
USING (user_id IS NULL);
-- No INSERT/UPDATE/DELETE policies: client-side writes are impossible.
-- The pipeline + API write via the service role, which bypasses RLS.
@@ -0,0 +1,17 @@
-- ---------------------------------------------------------------
-- 020 — ledger_entries team/opponent addendum (Session 59, pre-day-one).
--
-- Applied while ledger_entries is still at 0 rows (day one of the public
-- record starts tomorrow) so every row ever written carries the graded
-- player's team + opponent — unblocks VYNDR-on-team with zero backfill.
-- Both are captured from the real feed at write time; null when the feed
-- doesn't identify the player's team (never guessed).
-- ---------------------------------------------------------------
ALTER TABLE public.ledger_entries
ADD COLUMN IF NOT EXISTS team text NULL,
ADD COLUMN IF NOT EXISTS opponent text NULL;
-- VYNDR-on-team aggregate reads (public record scoped by team).
CREATE INDEX IF NOT EXISTS idx_ledger_public_team
ON public.ledger_entries (team) WHERE user_id IS NULL;
+91
View File
@@ -0,0 +1,91 @@
// Session 58 (Phase 1) — /api/ledger/mine (auth-scoped) + /api/ledger/model
// (public record + aggregate). Supabase + auth are mocked; the routes'
// scoping and honest-aggregate contracts are what's under test.
const express = require('express');
const request = require('supertest');
// requireAuth stub: Authorization present → user u1; else 401.
jest.mock('../../src/middleware/auth', () => ({
requireAuth: (req, res, next) => {
if (!req.headers.authorization) return res.status(401).json({ error: 'auth required' });
req.user = { id: 'u1', tier: 'analyst' };
return next();
},
}));
// Supabase service client stub — records filters so we can assert scoping.
const mockCaptured = { filters: [], rows: [] };
function mockChain() {
const b = {
_filters: [],
select() { return b; },
eq(col, val) { b._filters.push(['eq', col, val]); return b; },
is(col, val) { b._filters.push(['is', col, val]); return b; },
not(col, op, val) { b._filters.push(['not', col, op, val]); return b; },
gte(col, val) { b._filters.push(['gte', col, val]); return b; },
ilike(col, val) { b._filters.push(['ilike', col, val]); return b; },
order() { return b; },
limit() {
mockCaptured.filters.push(b._filters);
return Promise.resolve({ data: mockCaptured.rows, error: null, count: 0 });
},
then(resolve, reject) {
mockCaptured.filters.push(b._filters);
return Promise.resolve({ data: mockCaptured.rows, error: null, count: 0 }).then(resolve, reject);
},
};
return b;
}
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({ from: () => mockChain() }),
}));
process.env.SUPABASE_URL = 'https://test.supabase.co';
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key';
function mountApp() {
delete require.cache[require.resolve('../../src/routes/ledger')];
const routes = require('../../src/routes/ledger');
const app = express();
app.use('/api/ledger', routes);
return app;
}
beforeEach(() => {
mockCaptured.filters.length = 0;
mockCaptured.rows.length = 0;
});
describe('GET /api/ledger/mine', () => {
test('401 without auth', async () => {
const res = await request(mountApp()).get('/api/ledger/mine');
expect(res.status).toBe(401);
});
test('scopes rows to the authenticated user', async () => {
mockCaptured.rows.push({ id: 'r1', player_name: 'Judge', user_id: 'u1' });
const res = await request(mountApp())
.get('/api/ledger/mine?sport=mlb&tier=A')
.set('Authorization', 'Bearer token');
expect(res.status).toBe(200);
expect(res.body.entries).toHaveLength(1);
const filters = mockCaptured.filters[0];
expect(filters).toContainEqual(['eq', 'user_id', 'u1']);
expect(filters).toContainEqual(['eq', 'sport', 'mlb']);
expect(filters).toContainEqual(['ilike', 'grade', 'A%']);
});
});
describe('GET /api/ledger/model', () => {
test('public — returns the user_id-null record + an aggregate with the n<20 rule', async () => {
const res = await request(mountApp()).get('/api/ledger/model');
expect(res.status).toBe(200);
expect(res.body.min_sample).toBe(20);
expect(res.body.aggregate).toBeTruthy();
expect(res.body.aggregate.hit_pct).toBeNull(); // 0 settles → no percentage
// The entries query must be scoped to the PUBLIC record.
const entriesFilters = mockCaptured.filters.find((f) => f.some((x) => x[0] === 'is' && x[1] === 'user_id'));
expect(entriesFilters).toBeTruthy();
});
});
+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');
});
});
+36 -7
View File
@@ -129,14 +129,18 @@ describe('analyzeViaEngine1 — graceful degradation', () => {
player: 'Ghost', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out.grade).toBe('C');
expect(out.confidence).toBe(10);
expect(out.reasoning.summary).toMatch(/Unable to compute|provisional/);
// Session 58 (work-order 1.5) — this used to ship a hollow C at 10%
// confidence (the audit's model==line degenerate). Now it REFUSES.
expect(out.grade).toBeNull();
expect(out.insufficient_data).toBe(true);
expect(out.confidence).toBe(0);
expect(out.projection).toBeNull();
expect(out.reasoning.summary).toMatch(/INSUFFICIENT DATA — no read/);
expect(out.reasoning.summary).toContain("couldn't find");
expect(out.kill_conditions_triggered).toEqual([]);
});
test('partial data (player found, no game) still grades via engine1', async () => {
test('partial data WITHOUT a projection refuses honestly (no hollow grade)', async () => {
mockComputeReturn.current = {
features: {},
trap: { composite: 0, signals: {} },
@@ -155,11 +159,36 @@ describe('analyzeViaEngine1 — graceful degradation', () => {
player: 'P', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
// Did NOT fall through to fallbackLegacyResult — engine1 was invoked.
expect(out.grade).toBe('C');
expect(out.confidence).toBe(40);
// No l5/l20 reference ⇒ the model has no projection ⇒ no read. The old
// behavior graded C/40 here — a grade the model couldn't actually back.
expect(out.grade).toBeNull();
expect(out.insufficient_data).toBe(true);
expect(out.reasoning.summary).toContain('No game scheduled');
});
test('a projection unlocks the grade AND is attached to the result', async () => {
mockComputeReturn.current = {
features: { l5_avg: 28.4, l20_avg: 26.1 },
trap: { composite: 0, signals: {} },
consistency: { consistency: 'reliable', cv: 0.2, score: 0.7, games: 20 },
prop: { line: 25, direction: 'over' },
meta: { player: 'P', statType: 'points', book: 'dk', sport: 'nba',
teamAbbr: 'NYK', opponentAbbr: null, gameId: null, isHome: null,
gameLogs: [{ points: 25 }], errors: [] },
};
mockEngine1Return.current = {
grade: 'B', confidence: 0.6,
top_factors: [], all_factors: [],
};
const out = await analyzeViaEngine1({
player: 'P', stat_type: 'points', line: 25, direction: 'over', sport: 'nba',
});
expect(out.grade).toBe('B');
expect(out.insufficient_data).toBeUndefined();
expect(out.projection).toBe(28.4); // the REAL model reference, never the line
});
});
describe('analyzeViaEngine1 — interface verifications', () => {
+5 -2
View File
@@ -73,8 +73,10 @@ describe('analyzeViaEngine1 — soccer reasoning', () => {
});
test('altitude impact surfaces with venue context', async () => {
// goals_per_90 present: the model needs a projection to grade at all
// (Session 58 — no projection ⇒ INSUFFICIENT DATA refusal).
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
altitude_impact: 'high', venue_altitude_ft: 7349, home_continent: false,
goals_per_90: 0.4, altitude_impact: 'high', venue_altitude_ft: 7349, home_continent: false,
}, { venue: 'Estadio Azteca' }));
const result = await analyzeViaEngine1({
player: 'Visitor', stat_type: 'goals', line: 0.5, direction: 'over', sport: 'soccer',
@@ -95,8 +97,9 @@ describe('analyzeViaEngine1 — soccer reasoning', () => {
});
test('referee card rate surfaces when present', async () => {
// l5_avg present: the model needs a projection to grade (Session 58).
mockComputeFeaturesForProp.mockResolvedValueOnce(soccerFeatureResult({
referee_cards_per_game: 5.4, referee_name: 'Anthony Taylor',
l5_avg: 1.2, referee_cards_per_game: 5.4, referee_name: 'Anthony Taylor',
}));
const result = await analyzeViaEngine1({
player: 'Anyone', stat_type: 'cards', line: 0.5, direction: 'over', sport: 'soccer',
+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.');
});
});
+235
View File
@@ -0,0 +1,235 @@
// Session 58 (Phase 1) — ledgerService: the truth infrastructure.
// Everything runs against a fake Supabase client — zero network, zero env.
const ledger = require('../../src/services/ledgerService');
const { rowsFromSnapshot, computeClv, clvResultOf } = ledger.__internals;
// ---- fake Supabase client ------------------------------------------------
// Minimal chainable stub for the exact query shapes ledgerService uses.
function fakeSb() {
const calls = { upserts: [], updates: [] };
const state = { selectResults: [], selectCursor: 0, countResult: 0 };
function builder() {
const b = {
_update: null,
upsert(rows, opts) {
calls.upserts.push({ rows, opts });
return Promise.resolve({ error: null });
},
update(values) { b._update = values; return b; },
select() { return b; },
eq() { return b; },
is() { return b; },
not() { return b; },
lt() { return b; },
gte() { return b; },
in(col, ids) {
if (b._update) {
calls.updates.push({ values: b._update, ids });
return Promise.resolve({ error: null });
}
return terminal();
},
order() { return b; },
limit() { return terminal(); },
then(resolve, reject) { return terminal().then(resolve, reject); },
};
function terminal() {
if (b._update) {
calls.updates.push({ values: b._update });
return Promise.resolve({ error: null });
}
const data = state.selectResults[state.selectCursor] ?? [];
state.selectCursor += 1;
return Promise.resolve({ data, error: null, count: state.countResult });
}
return b;
}
return { from: () => builder(), _calls: calls, _state: state };
}
// ---- fixtures --------------------------------------------------------------
const NOW = '2026-07-10T18:00:00.000Z';
const GRADE = {
player: 'Aaron Judge', player_name: 'Aaron Judge', stat_type: 'home_runs',
line: 0.5, direction: 'over', grade: 'A', confidence: 78, edge_pct: 12.4,
projection: 0.9,
gradedAt: { line: 0.5, odds: -115, timestamp: NOW },
};
const PROP = {
player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5,
home_team: 'NYY', away_team: 'BOS', game_time: '2026-07-10T23:05:00Z',
book: 'draftkings', over_odds: -115, under_odds: -105,
};
describe('rowsFromSnapshot — the write shape', () => {
test('builds a public row with REAL captured market values', () => {
const rows = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
expect(rows).toHaveLength(1);
const r = rows[0];
expect(r.user_id).toBeNull();
expect(r.player_key).toBe('aaron judge');
expect(r.line).toBe(0.5); // the locked book line
expect(r.locked_odds).toBe('-115'); // real odds at grade time
expect(r.book).toBe('draftkings');
expect(r.model_value).toBe(0.9); // MODEL output, distinct from line
expect(r.game_date).toBe('2026-07-10'); // 23:05Z = 19:05 ET same day
expect(r.game_id).toBe('mlb:2026-07-10:BOS@NYY');
});
// Session 59 — team/opponent addendum (migration 020).
test('captures team + opponent from the real feed (full-name MLB teams)', () => {
const grade = { ...GRADE, team: 'New York Yankees' };
const prop = { ...PROP, home_team: 'New York Yankees', away_team: 'Boston Red Sox' };
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
expect(r.team).toBe('New York Yankees');
expect(r.opponent).toBe('Boston Red Sox');
});
test('opponent is NEVER guessed: team not in the game → opponent null', () => {
const grade = { ...GRADE, team: 'Tampa Bay Rays' };
const prop = { ...PROP, home_team: 'Milwaukee Brewers', away_team: 'Pittsburgh Pirates' };
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
expect(r.team).toBe('Tampa Bay Rays');
expect(r.opponent).toBeNull();
});
test('no team from the stats resolve → both null (absent beats wrong)', () => {
const [r] = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
expect(r.team).toBeNull();
expect(r.opponent).toBeNull();
});
test('refused reads and grade-less entries never become rows', () => {
const refused = { ...GRADE, grade: null, insufficient_data: true };
const gradeless = { ...GRADE, grade: undefined };
expect(rowsFromSnapshot('mlb', [refused, gradeless], [PROP], NOW)).toEqual([]);
});
test('a grade with no captured line is dropped — never a fabricated line', () => {
const noLine = { ...GRADE, line: undefined, gradedAt: { line: null, odds: null, timestamp: NOW } };
expect(rowsFromSnapshot('mlb', [noLine], [PROP], NOW)).toEqual([]);
});
});
describe('recordPipelineGrades — idempotent upsert', () => {
test('upserts with ignoreDuplicates on the dedupe constraint', async () => {
const sb = fakeSb();
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], { sb, now: () => NOW });
expect(res.written).toBe(1);
expect(sb._calls.upserts).toHaveLength(1);
const { opts } = sb._calls.upserts[0];
expect(opts.onConflict).toBe('user_id,player_key,stat,line,side,game_id');
expect(opts.ignoreDuplicates).toBe(true); // re-runs never overwrite the lock
});
test('no-ops without supabase env when no client injected', async () => {
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], {});
expect(res.skipped).toBeTruthy();
});
});
describe('computeClv — signed by side (Phase 1 amendment)', () => {
test('OVER: closing below the locked line = positive = beat', () => {
expect(computeClv('over', 1.5, 1.0)).toBe(0.5);
expect(clvResultOf(computeClv('over', 1.5, 1.0))).toBe('beat');
});
test('OVER: closing above the locked line = negative = faded', () => {
expect(computeClv('over', 1.5, 2.0)).toBe(-0.5);
expect(clvResultOf(computeClv('over', 1.5, 2.0))).toBe('faded');
});
test('UNDER: inverse signs', () => {
expect(computeClv('under', 1.5, 2.0)).toBe(0.5); // market rose toward the under
expect(clvResultOf(computeClv('under', 1.5, 2.0))).toBe('beat');
expect(computeClv('under', 1.5, 1.0)).toBe(-0.5);
expect(clvResultOf(computeClv('under', 1.5, 1.0))).toBe('faded');
});
test('unchanged line = flat; missing closing = null (absent, not zero)', () => {
expect(clvResultOf(computeClv('over', 1.5, 1.5))).toBe('flat');
expect(computeClv('over', 1.5, null)).toBeNull();
expect(clvResultOf(null)).toBeNull();
});
});
describe('settleLedger — outcome + CLV vs the real result', () => {
test('settles hit/miss/push from the game log and stamps CLV', async () => {
const sb = fakeSb();
// 1st select: open ids; 2nd: full rows.
sb._state.selectResults = [
[{ id: 'r1' }, { id: 'r2' }],
[
{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: 0.5, game_date: '2026-07-09' },
{ id: 'r2', player_name: 'Aaron Judge', stat: 'hits', line: 1.5, side: 'over', closing_line: 2.5, game_date: '2026-07-09' },
],
];
const getPlayerStats = async () => ({
found: true,
last10: [{ date: '2026-07-09', stat: { homeRuns: 1, hits: 1 } }],
});
const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10' });
expect(res.settled).toBe(2);
const byOutcome = sb._calls.updates.map((u) => u.values);
expect(byOutcome[0]).toMatchObject({ outcome: 'hit', actual_value: 1, clv: 0, clv_result: 'flat' });
// 1 hit vs o1.5 = miss; locked 1.5 closed 2.5 for an over = faded.
expect(byOutcome[1]).toMatchObject({ outcome: 'miss', actual_value: 1, clv: -1, clv_result: 'faded' });
});
test('no game-log row for the date → stays pending (never guesses)', async () => {
const sb = fakeSb();
sb._state.selectResults = [
[{ id: 'r1' }],
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
];
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10' });
expect(res.settled).toBe(0);
expect(res.pending).toBe(1);
expect(sb._calls.updates).toHaveLength(0);
});
});
describe('captureClosing — real feed values only', () => {
test('updates open rows from the current odds; unmatched rows untouched', async () => {
const sb = fakeSb();
sb._state.selectResults = [[
{ id: 'r1', player_key: 'aaron judge', stat: 'home_runs', side: 'over' },
{ id: 'r2', player_key: 'ghost player', stat: 'hits', side: 'over' },
]];
const res = await ledger.captureClosing('mlb', [PROP], { sb, gameDate: '2026-07-10' });
expect(res.updated).toBe(1); // only the matched prop
expect(sb._calls.updates[0].values).toEqual({ closing_line: 0.5, closing_odds: '-115' });
expect(sb._calls.updates[0].ids).toEqual(['r1']);
});
});
describe('getModelAggregate — never a % under min sample', () => {
test('below 20 settles → hit_pct/beat_close_pct null, counts real', async () => {
const sb = fakeSb();
sb._state.selectResults = [
Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
];
sb._state.countResult = 42;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.settled).toBe(5);
expect(agg.hits).toBe(5);
expect(agg.hit_pct).toBeNull(); // n<20 — RECORD BUILDING
expect(agg.beat_close_pct).toBeNull();
expect(agg.pending).toBe(42);
});
test('at 20+ settles → both percentages render', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 13 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded' })),
];
sb._state.selectResults = [rows];
sb._state.countResult = 3;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.settled).toBe(20);
expect(agg.hit_pct).toBe(65); // 13 / (13+7)
expect(agg.beat_close_pct).toBe(65); // 13 beat / 20 with clv
});
});
+51
View File
@@ -125,3 +125,54 @@ describe('mlbStatsAdapter — batter vs pitcher', () => {
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
});
});
// Session 59 (work-order 1.6) — canonical player resolution. The old matcher
// deleted accented letters ("Sánchez" → "snchez") and substring-guessed on a
// miss, which could return ANOTHER player's id (→ wrong last-10 log).
describe('searchPlayer — canonical nameKey resolution', () => {
const PLAYERS = {
data: {
people: [
{ id: 100, fullName: 'Cristopher Sanchez', currentTeam: { name: 'Philadelphia Phillies', id: 1 }, primaryPosition: { abbreviation: 'P' } },
{ id: 200, fullName: 'Brandon Lowe', currentTeam: { name: 'Tampa Bay Rays', id: 2 }, primaryPosition: { abbreviation: '2B' } },
{ id: 201, fullName: 'Nathaniel Lowe', currentTeam: { name: 'Texas Rangers', id: 3 }, primaryPosition: { abbreviation: '1B' } },
{ id: 202, fullName: 'Josh Lowe', currentTeam: { name: 'Tampa Bay Rays', id: 2 }, primaryPosition: { abbreviation: 'OF' } },
{ id: 300, fullName: 'Matthew Boyd', currentTeam: { name: 'Chicago Cubs', id: 4 }, primaryPosition: { abbreviation: 'P' } },
],
},
};
test('accented query resolves to the unaccented MLB record (Sánchez ≡ Sanchez)', async () => {
mockAxiosGet.mockResolvedValue(PLAYERS);
const hit = await adapter.searchPlayer('Cristopher Sánchez');
expect(hit).toBeTruthy();
expect(hit.id).toBe(100);
});
test('never returns another player on a shared last name (Brandon ≠ Nathaniel Lowe)', async () => {
mockAxiosGet.mockResolvedValue(PLAYERS);
const hit = await adapter.searchPlayer('Brandon Lowe');
expect(hit.id).toBe(200);
expect(hit.team).toBe('Tampa Bay Rays');
});
test('nickname resolves via nameKey (Matt Boyd → Matthew Boyd)', async () => {
mockAxiosGet.mockResolvedValue(PLAYERS);
const hit = await adapter.searchPlayer('Matt Boyd');
expect(hit && hit.id).toBe(300);
});
test('ambiguous fallback returns null — a missing profile beats a wrong one', async () => {
mockAxiosGet.mockResolvedValue({
data: {
people: [
{ id: 400, fullName: 'Jose Ramirez', currentTeam: { name: 'A' }, primaryPosition: { abbreviation: '3B' } },
{ id: 401, fullName: 'Jorge Ramirez', currentTeam: { name: 'B' }, primaryPosition: { abbreviation: 'P' } },
],
},
});
// "J. Ramirez" collapses to first-initial J — TWO candidates → null.
const hit = await adapter.searchPlayer('Jhon Ramirez');
expect(hit).toBeNull();
});
});
+28
View File
@@ -0,0 +1,28 @@
// Session 59 (work-order 2.3) — honest waiting states derive from the REAL
// cron schedule (UTC 14,19,22,1,3). A wrong time is a lie; keep HOURS_UTC in
// sync with snapshotScheduler's default.
const { HOURS_UTC, nextRunAt, nextRunLabelET } = require('../../web/src/lib/pipelineSchedule');
const { HOURS_UTC: BACKEND_HOURS } = require('../../src/snapshotScheduler');
describe('pipelineSchedule', () => {
test('mirrors the backend default cron hours EXACTLY', () => {
expect([...HOURS_UTC].sort((a, b) => a - b)).toEqual([...BACKEND_HOURS].sort((a, b) => a - b));
});
test('next run after 15:30 UTC is 19:00 UTC same day', () => {
const now = new Date('2026-07-10T15:30:00Z');
expect(nextRunAt(now).toISOString()).toBe('2026-07-10T19:00:00.000Z');
});
test('next run after 23:30 UTC rolls to 01:00 UTC next day', () => {
const now = new Date('2026-07-10T23:30:00Z');
expect(nextRunAt(now).toISOString()).toBe('2026-07-11T01:00:00.000Z');
});
test('label renders an ET clock time', () => {
// 19:00 UTC = 3:00 PM ET in July (EDT).
const label = nextRunLabelET(new Date('2026-07-10T15:30:00Z'));
expect(label).toBe('~3:00 PM ET');
});
});
+70
View File
@@ -95,3 +95,73 @@ describe('legacy GameCard uses BookChip (brand colors)', () => {
expect(src).toContain('book={r.book}');
});
});
// Session 59 (work-order 1.6) — THE JOIN INVARIANT. A prop can only attach
// to a game where the player's team is one of the two participants. The
// audit found Brandon Lowe (TB) rendered under MIL@PIT — a bad feed row the
// overlay happily displayed.
describe('buildPlayerStripsFromProps — game-participant join guard', () => {
const GAME = { home: 'Milwaukee Brewers', away: 'Pittsburgh Pirates' };
it('drops a graded prop whose player team is not in the game (TB ∉ MIL@PIT)', () => {
const props = [{ player: 'Brandon Lowe', stat_type: 'hits', line: 1.5 }];
const gradeIndex = adapter.indexGrades([
{ player: 'Brandon Lowe', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A',
team: 'Tampa Bay Rays', gradedAt: { line: 1.5, timestamp: '2026-07-10T02:00:00Z' } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME);
expect(strips).toEqual([]);
});
it('keeps a prop whose player team IS a participant + stamps the team', () => {
const props = [{ player: 'Willy Adames', stat_type: 'hits', line: 1.5 }];
const gradeIndex = adapter.indexGrades([
{ player: 'Willy Adames', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B',
team: 'Milwaukee Brewers', gradedAt: { line: 1.5, timestamp: '2026-07-10T02:00:00Z' } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME);
expect(strips).toHaveLength(1);
expect(strips[0].team).toBe('Milwaukee Brewers');
});
it('keeps props with no team info (cannot verify ≠ wrong)', () => {
const props = [{ player: 'Mystery Guy', stat_type: 'hits', line: 0.5 }];
const strips = adapter.buildPlayerStripsFromProps(props, {}, {}, Date.now(), GAME);
expect(strips).toHaveLength(1);
expect(strips[0].props[0].awaiting).toBe(true);
});
// The invariant itself, as the suite-failing rule the work order asked for:
it('INVARIANT: every rendered strip with a known team belongs to the game', () => {
const props = [
{ player: 'Willy Adames', stat_type: 'hits', line: 1.5 },
{ player: 'Brandon Lowe', stat_type: 'hits', line: 1.5 },
{ player: 'Unknown Player', stat_type: 'runs', line: 0.5 },
];
const gradeIndex = adapter.indexGrades([
{ player: 'Willy Adames', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', team: 'Milwaukee Brewers', gradedAt: { line: 1.5, timestamp: 'x' } },
{ player: 'Brandon Lowe', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', team: 'Tampa Bay Rays', gradedAt: { line: 1.5, timestamp: 'x' } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME);
const participants = ['milwaukee brewers', 'pittsburgh pirates'];
for (const s of strips) {
if (!s.team) continue;
expect(participants).toContain(String(s.team).toLowerCase());
}
expect(strips.find((s) => String(s.team).toLowerCase() === 'tampa bay rays')).toBeUndefined();
});
});
// Sánchez/Sanchez single-key regression (work-order 1.6 #4).
describe('diacritic variants resolve to ONE key across the slate', () => {
it('an accented prop matches an unaccented snapshot grade (one strip, graded)', () => {
const props = [{ player: 'Cristopher Sánchez', stat_type: 'strikeouts', line: 5.5 }];
const gradeIndex = adapter.indexGrades([
{ player: 'Cristopher Sanchez', stat_type: 'strikeouts', line: 5.5, direction: 'over', grade: 'A',
gradedAt: { line: 5.5, timestamp: 'x' } },
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips).toHaveLength(1);
expect(strips[0].props[0].grade).toBe('A');
});
});
+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');
});
});
+73 -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');
@@ -104,3 +110,66 @@ describe('Phase F — PWA manifest + viewport (§11)', () => {
expect(read('app/layout.tsx')).toContain("viewportFit: 'cover'");
});
});
// ── Session 59 — work-order Phase 3 (mobile P0) + Phase 2 slate UX ──────────
describe('Phase 3.1 — the tab bar is the mobile nav EVERYWHERE except auth', () => {
const src = read('components/BottomTabBar.tsx');
it('does NOT hide on the landing (anon phones had zero navigation)', () => {
const hideOn = src.match(/const HIDE_ON = new Set\(\[([^\]]*)\]\)/);
expect(hideOn).toBeTruthy();
expect(hideOn[1]).not.toContain("'/'");
['/login', '/signup', '/auth/callback'].forEach((p) => expect(hideOn[1]).toContain(p));
});
});
describe('Phase 3.2 — overflow containment at 390px', () => {
const css = read('app/globals.css');
it('document never scrolls horizontally on phones', () => {
expect(css).toMatch(/@media \(max-width: 767px\)[\s\S]*?html, body \{ overflow-x: hidden/);
});
it('venue drops first on phones (time/pitchers never truncate)', () => {
expect(css).toContain('.gc-venue { display: none');
expect(read('components/vyndr/GameCard.tsx')).toContain('className="gc-venue"');
});
it('book table collapses to a best-line summary with an expander below md', () => {
expect(css).toContain('.gl-summary { display: none; }');
expect(css).toContain('.gl-full:not(.gl-expanded) { display: none');
const card = read('components/vyndr/GameCard.tsx');
expect(card).toContain('gl-summary');
expect(card).toContain('BOOKS {linesExpanded');
});
});
describe('Phase 2.2 — card collapse (6-prop cap, ALL N READS)', () => {
const card = read('components/vyndr/GameCard.tsx');
it('caps visible graded props and sorts strips best-grade-first', () => {
expect(card).toContain('MAX_VISIBLE_PROPS = 6');
expect(card).toMatch(/sort\(\(a, b\) => stripBestRank\(a\) - stripBestRank\(b\)\)/);
});
it('offers the ALL N READS expander in place', () => {
expect(card).toContain('ALL {collapsed.totalReads} READS →');
expect(card).toContain('COLLAPSE ↑');
});
});
describe('Phase 2.1 — sport tabs are THE filter, URL-driven', () => {
const slate = read('components/Slate.tsx');
it('reads and writes ?sport= on the URL', () => {
expect(slate).toContain(".get('sport')"); // tabFromUrl reads the deep link
expect(slate).toContain("url.searchParams.set('sport', t)");
expect(slate).toContain('history.replaceState');
});
it('the dashboard has NO second tablist — it subscribes via onTabChange', () => {
const dash = read('app/dashboard/page.tsx');
expect(dash).toContain('onTabChange');
expect(dash).not.toMatch(/role="tablist"\s+aria-label="Sport"/);
});
});
describe('Phase 2.3 — honest waiting states', () => {
it('awaiting props show the real next pipeline run, not passive mystery', () => {
expect(read('components/vyndr/StatStrip.tsx')).toContain('nextRunLabelET');
expect(read('app/dashboard/page.tsx')).toContain('nextRunLabelET');
});
});
+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" },
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Personal ledger proxy (Session 58, Phase 1) — forwards GET /api/ledger/mine
* with the caller's Authorization header (Express requireAuth scopes rows to
* the authenticated user).
*/
export async function GET(req: NextRequest) {
const auth = req.headers.get('authorization');
if (!auth) return NextResponse.json({ entries: [] }, { status: 401 });
try {
const qs = req.nextUrl.searchParams.toString();
const upstream = await fetch(`${BACKEND_URL}/api/ledger/mine${qs ? `?${qs}` : ''}`, {
headers: { Accept: 'application/json', Authorization: auth },
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({ entries: [] }));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ entries: [] }, { status: 200 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Public model-record proxy (Session 58, Phase 1) — forwards
* GET /api/ledger/model (the pipeline's user_id-null rows + 30d aggregate).
*/
export async function GET(req: NextRequest) {
try {
const qs = req.nextUrl.searchParams.toString();
const upstream = await fetch(`${BACKEND_URL}/api/ledger/model${qs ? `?${qs}` : ''}`, {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({ entries: [], aggregate: null }));
return NextResponse.json(data, {
status: upstream.ok ? 200 : upstream.status,
headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' },
});
} catch {
return NextResponse.json({ entries: [], aggregate: null }, { status: 200 });
}
}
+88 -1
View File
@@ -115,7 +115,21 @@ export async function POST(req: NextRequest) {
let scansRemaining: number | null = null;
if (user && sb) {
// Session 58 (work-order 1.5) — a refused read (no projection) writes
// NOTHING: no scan_history, no ledger row. No hollow rows anywhere.
const refused = data?.insufficient_data === true || !data?.grade;
if (user && sb && !refused) {
// Phase 1 — persist the read to the ledger (authenticated users only;
// anonymous scans are never written: a null user_id row would pollute
// the PUBLIC model record, which is pipeline-only). The line/book are
// the REAL book values the slate pre-filled; locked odds are enriched
// from the cached odds feed when the prop matches. Fire-and-forget —
// the scan response never waits on the ledger.
void writeLedgerEntry(sb, user.id, body, data);
}
if (user && sb && !refused) {
void sb.rpc('increment_parlay_leg_frequency', {
p_player: body.player,
p_stat: body.stat,
@@ -162,3 +176,76 @@ export async function POST(req: NextRequest) {
return jsonError(502, 'The engine hit a wall. Try that read again.');
}
}
/**
* Session 58 (Phase 1) — persist a completed user scan to ledger_entries.
*
* DATA SEMANTICS: `line`/`book` are the real book values the user scanned
* (the slate pre-fills them from the odds feed). `locked_odds` attaches ONLY
* when the cache-only snapshot carries the SAME line for this prop — odds
* from a different line would be a fabrication, so absent beats wrong.
* Upsert on the dedupe constraint: a double-tap never duplicates.
*/
async function writeLedgerEntry(
sb: NonNullable<ReturnType<typeof getServiceRoleSupabase>>,
userId: string,
body: ScanBody,
data: { grade?: string; projection?: number; confidence?: number; edge_pct?: number },
) {
try {
const { nameKey, normalizeName } = await import('@/lib/playerName');
const sport = body.sport.toLowerCase();
const playerKey = nameKey(body.player);
const gameDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
// Cache-only snapshot read (never triggers an odds fetch → no quota).
let lockedOdds: string | null = null;
let team: string | null = null;
try {
const snap = await fetch(`${BACKEND_URL}/api/snapshot/${sport}`, {
headers: { Accept: 'application/json' },
cache: 'no-store',
}).then((r) => (r.ok ? r.json() : null));
interface SnapGrade { player?: string; player_name?: string; stat_type?: string; stat?: string; team?: string | null; gradedAt?: { line?: number; odds?: number | string | null } }
const grades: SnapGrade[] = snap?.grades || [];
// Team match needs only player identity; odds additionally need the SAME line.
const samePlayer = grades.filter(
(g) => nameKey(g.player || g.player_name || '') === playerKey
&& String(g.stat_type || g.stat || '').toLowerCase() === body.stat.toLowerCase(),
);
team = samePlayer.find((g) => g.team)?.team ?? null;
const match = samePlayer.find((g) => g.gradedAt && Number(g.gradedAt.line) === Number(body.line));
if (match?.gradedAt?.odds != null) lockedOdds = String(match.gradedAt.odds);
} catch { /* absent beats wrong */ }
await sb.from('ledger_entries').upsert(
{
user_id: userId,
player_key: playerKey,
player_name: normalizeName(body.player).display || body.player,
sport,
stat: body.stat.toLowerCase(),
line: body.line,
side: body.direction,
locked_odds: lockedOdds,
book: body.book ?? 'draftkings',
// Session 59 — team from the snapshot's stats resolve (real feed);
// opponent stays null on manual scans (no game context — never guessed).
team,
opponent: null,
grade: data.grade,
edge: typeof data.edge_pct === 'number' ? data.edge_pct : null,
confidence: typeof data.confidence === 'number' ? data.confidence : null,
model_value: typeof data.projection === 'number' ? data.projection : null,
graded_at: new Date().toISOString(),
game_id: `manual:${sport}:${gameDate}:${playerKey}`,
game_date: gameDate,
},
{ onConflict: 'user_id,player_key,stat,line,side,game_id', ignoreDuplicates: true },
);
} catch (err) {
console.warn('[scan] ledger write failed', err);
}
}
+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 });
}
}
+28 -48
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useParlay } from '@/contexts/ParlayContext';
@@ -11,6 +11,10 @@ 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';
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
import { nextRunLabelET } from '@/lib/pipelineSchedule';
type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -67,8 +71,6 @@ interface RecentScan {
created_at: string;
}
const SPORT_TABS: Sport[] = ['NBA', 'MLB', 'WNBA'];
const SPORT_COLOR: Record<Sport, string> = {
NBA: '#E94B3C',
MLB: '#1E90FF',
@@ -178,12 +180,6 @@ export default function DashboardPage() {
}
}, [user]);
const gameCountsBySport = useMemo(() => {
// Updated whenever the sport's slate refreshes. We only know counts for
// the current sport — others show a dash until clicked.
return { [sport]: games?.length ?? 0 } as Partial<Record<Sport, number>>;
}, [sport, games]);
if (authLoading || !user) {
return (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
@@ -232,42 +228,23 @@ export default function DashboardPage() {
{/* Session 13 — Browse-first slate. Owns its own sport-tab UI,
search, and inline grading. Renders ABOVE the existing
intelligence sections (Top Graded / Most Parlayed / Recent
Reads) which serve as supplementary surfaces. */}
<Slate tier={tier} initialTab={primaryTab} preferredBooks={prefBooks} key={primaryTab} />
{/* Legacy sport tabs — supplementary, kept for the existing
Top Graded / Most Parlayed flows below. */}
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginTop: 40, marginBottom: 32, borderBottom: '1px solid var(--border)' }}>
{SPORT_TABS.map((s) => {
const active = s === sport;
const count = gameCountsBySport[s];
return (
<button
key={s}
role="tab"
aria-selected={active}
onClick={() => setSport(s)}
style={{
padding: '12px 20px',
background: 'transparent',
border: 'none',
borderBottom: `2px solid ${active ? SPORT_COLOR[s] : 'transparent'}`,
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
fontFamily: 'inherit',
fontWeight: active ? 600 : 500,
fontSize: 14,
cursor: 'pointer',
marginBottom: -1,
}}
>
{s}{' '}
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginLeft: 4 }}>
{active && count != null ? `(${count})` : '·'}
</span>
</button>
);
})}
</div>
Reads) which serve as supplementary surfaces.
Session 59 (work-order 2.1) — the Slate's tabs are THE filter
(URL-driven, ?sport=). The legacy duplicate tab row below is GONE;
the supplementary sections follow the same selection via
onTabChange. Don't re-add a second tablist. */}
<Slate
tier={tier}
initialTab={primaryTab}
preferredBooks={prefBooks}
key={primaryTab}
onTabChange={(t) => {
if (t === 'nba') setSport('NBA');
else if (t === 'mlb') setSport('MLB');
else if (t === 'wnba') setSport('WNBA');
// 'all' / 'soccer' → the legacy sections keep their last sport.
}}
/>
{/* Top grades horizontal scroll */}
<Section
@@ -277,7 +254,8 @@ export default function DashboardPage() {
{topGrades === null ? (
<SkeletonRow />
) : topGrades.length === 0 ? (
<p style={emptyCopy}>No grades yet. The model is waiting on lines.</p>
// Session 59 (work-order 2.3) — the real schedule, not passive waiting.
<p style={emptyCopy}>No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.</p>
) : (
<div
style={{
@@ -325,14 +303,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>
+23
View File
@@ -1270,3 +1270,26 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
@media (max-width: 768px) {
.terminal-grid { grid-template-columns: 1fr !important; }
}
/* ── Session 59 (work-order 3.2) — overflow containment at 390px ──────── */
/* Horizontal scroll is CONTAINED to strips (tabs, chips, book tables) —
the document itself never scrolls sideways. */
@media (max-width: 767px) {
html, body { overflow-x: hidden; }
}
/* Card meta: venue drops FIRST on phones so time/pitchers never truncate. */
@media (max-width: 639px) {
.gc-venue { display: none !important; }
}
/* GAME LINES: full 8-book table on desktop; on phones it collapses to the
best-line summary row (.gl-summary) with an "N BOOKS ⌄" expander that
reveals the full table (.gl-full.gl-expanded). */
.gl-summary { display: none; }
@media (max-width: 639px) {
.gl-summary { display: flex !important; }
.gl-full:not(.gl-expanded) { display: none !important; }
.gl-full.gl-expanded { margin-top: 10px; }
}
+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 />
+235 -166
View File
@@ -1,73 +1,107 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard';
import { useAuth } from '@/contexts/AuthContext';
type Sport = 'ALL' | 'NBA' | 'MLB' | 'WNBA';
type TierFilter = 'ALL' | 'A' | 'B' | 'C';
/**
* The Ledger (Session 58, Phase 1) — the truth surface, now backed by the
* persistent `ledger_entries` table.
*
* MY READS — the user's own scans, written the moment a scan completes.
* MODEL — the PUBLIC pipeline record (every pre-grade, misses included).
*
* DATA SEMANTICS: lines/odds/books shown here are real captured book values
* (mono, rendered as fact). model_value is VYNDR output and always carries
* the MODEL label. The aggregate header NEVER renders a percentage under 20
* settles — it shows the building record honestly instead.
*/
interface LedgerEntry {
type Tab = 'mine' | 'model';
type SportFilter = 'ALL' | 'NBA' | 'MLB' | 'WNBA';
type TierFilter = 'ALL' | 'A' | 'B' | 'C' | 'D';
interface LedgerRow {
id: string;
player: string;
player_name: string;
sport: string;
stat: string;
line: number;
direction: 'over' | 'under';
sport: Exclude<Sport, 'ALL'>;
side: 'over' | 'under';
locked_odds?: string | null;
book?: string | null;
grade: string;
projection?: number;
actual?: number;
hit: boolean | null;
miss_reason?: string;
edge?: number | null;
confidence?: number | null;
model_value?: number | null;
graded_at: string;
game_date: string;
closing_line?: number | null;
clv?: number | null;
clv_result?: 'beat' | 'faded' | 'flat' | null;
outcome?: 'hit' | 'miss' | 'push' | null;
actual_value?: number | null;
revised_from_grade?: string | null;
}
interface AccuracyBucket {
tier: string;
interface ModelAggregate {
settled: number;
hits: number;
losses: number;
pct: number;
misses: number;
pushes: number;
hit_pct: number | null;
clv_sample: number;
clv_beat: number;
beat_close_pct: number | null;
pending: number;
min_sample?: number;
}
const SPORT_COLOR: Record<Exclude<Sport, 'ALL'>, string> = {
NBA: '#E94B3C',
MLB: '#1E90FF',
WNBA: '#FFB347',
const SPORT_COLOR: Record<string, string> = {
nba: '#E94B3C',
mlb: '#1E90FF',
wnba: '#FFB347',
soccer: '#7BC96F',
};
export default function LedgerPage() {
const [sport, setSport] = useState<Sport>('ALL');
const { session } = useAuth();
const [tab, setTab] = useState<Tab>('mine');
const [sport, setSport] = useState<SportFilter>('ALL');
const [tier, setTier] = useState<TierFilter>('ALL');
const [entries, setEntries] = useState<LedgerEntry[] | null>(null);
const [accuracy, setAccuracy] = useState<AccuracyBucket[] | null>(null);
const [rows, setRows] = useState<LedgerRow[] | null>(null);
const [aggregate, setAggregate] = useState<ModelAggregate | null>(null);
const [minSample, setMinSample] = useState(20);
useEffect(() => {
const load = useCallback(async () => {
setRows(null);
const params = new URLSearchParams();
if (sport !== 'ALL') params.set('sport', sport);
if (sport !== 'ALL') params.set('sport', sport.toLowerCase());
if (tier !== 'ALL') params.set('tier', tier);
try {
if (tab === 'mine') {
const token = session?.access_token;
if (!token) { setRows([]); return; }
const data = await fetch(`/api/ledger/mine?${params}`, {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());
setRows(Array.isArray(data?.entries) ? data.entries : []);
} else {
const data = await fetch(`/api/ledger/model?${params}`).then((r) => r.json());
setRows(Array.isArray(data?.entries) ? data.entries : []);
setAggregate(data?.aggregate ?? null);
if (Number(data?.min_sample) > 0) setMinSample(Number(data.min_sample));
}
} catch {
setRows([]);
}
}, [tab, sport, tier, session]);
Promise.all([
fetch(`/api/ledger?${params}`).then((r) => r.json()).catch(() => ({ entries: [] })),
fetch('/api/ledger/accuracy').then((r) => r.json()).catch(() => ({ buckets: [] })),
]).then(([entriesData, accuracyData]) => {
setEntries(Array.isArray(entriesData?.entries) ? entriesData.entries : []);
setAccuracy(Array.isArray(accuracyData?.buckets) ? accuracyData.buckets : []);
});
}, [sport, tier]);
const overall = useMemo(() => {
if (!accuracy?.length) return null;
const totals = accuracy.reduce(
(acc, b) => ({ h: acc.h + b.hits, l: acc.l + b.losses }),
{ h: 0, l: 0 },
);
const total = totals.h + totals.l;
if (!total) return null;
return { hits: totals.h, losses: totals.l, pct: Math.round((totals.h / total) * 100) };
}, [accuracy]);
useEffect(() => { void load(); }, [load]);
return (
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '32px 16px 120px' }}>
<header style={{ marginBottom: 32 }}>
<header style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 32, fontWeight: 700, letterSpacing: '-0.03em', marginBottom: 6 }}>
The Ledger.
</h1>
@@ -76,122 +110,49 @@ export default function LedgerPage() {
</p>
</header>
{/* Accuracy header strip */}
{accuracy && accuracy.length > 0 && (
<section
className="surface diagonal-cut animate-fade-up"
style={{
padding: 24,
marginBottom: 24,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 12,
}}
>
{accuracy.map((b) => (
<AccuracyTile key={b.tier} bucket={b} />
))}
{overall && (
<AccuracyTile
bucket={{ tier: 'Overall', hits: overall.hits, losses: overall.losses, pct: overall.pct }}
highlight
/>
)}
</section>
{/* Tabs */}
<div role="tablist" aria-label="Ledger view" style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border)' }}>
{([['mine', 'MY READS'], ['model', 'MODEL']] as [Tab, string][]).map(([id, label]) => {
const active = tab === id;
return (
<button
key={id}
role="tab"
aria-selected={active}
onClick={() => setTab(id)}
className="mono"
style={{
padding: '12px 20px', background: 'transparent', border: 'none',
borderBottom: `2px solid ${active ? 'var(--g-a, #00D4A0)' : 'transparent'}`,
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: 700, fontSize: 12, letterSpacing: '0.08em', cursor: 'pointer', marginBottom: -1,
}}
>
{label}
</button>
);
})}
</div>
{/* MODEL aggregate header — never a percentage under min_sample. */}
{tab === 'model' && aggregate && (
<ModelHeader agg={aggregate} minSample={minSample} />
)}
{/* Filters */}
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<FilterRow label="Sport" value={sport} onChange={(v) => setSport(v as Sport)} options={['ALL', 'NBA', 'MLB', 'WNBA']} />
<FilterRow label="Grade" value={tier} onChange={(v) => setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C']} />
<FilterRow label="Sport" value={sport} onChange={(v) => setSport(v as SportFilter)} options={['ALL', 'NBA', 'MLB', 'WNBA']} />
<FilterRow label="Grade" value={tier} onChange={(v) => setTier(v as TierFilter)} options={['ALL', 'A', 'B', 'C', 'D']} />
</div>
{/* Grid */}
{entries === null ? (
{rows === null ? (
<p className="mono" style={{ color: 'var(--text-tertiary)', padding: 32, textAlign: 'center' }}>Loading</p>
) : entries.length === 0 ? (
<div
className="surface diagonal-cut tex-scan"
style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}
>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>
No grades yet.
</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
Read your first prop to start building your Ledger. Every grade you run shows up here, with the result.
</p>
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
Read a Prop
</a>
</div>
) : rows.length === 0 ? (
<EmptyLedger tab={tab} />
) : (
<div
style={{
display: 'grid',
gap: 12,
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
}}
>
{entries.map((entry, i) => (
<article
key={entry.id}
className={`surface diagonal-cut animate-fade-up stagger-${(i % 6) + 1}`}
style={{ padding: 16 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<span
className="mono"
style={{
fontSize: 10,
fontWeight: 700,
padding: '2px 8px',
borderRadius: 999,
background: `${SPORT_COLOR[entry.sport]}1F`,
color: SPORT_COLOR[entry.sport],
}}
>
{entry.sport}
</span>
<GradePill grade={entry.grade} />
</div>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{entry.player}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 12 }}>
{entry.direction} {entry.line} {entry.stat.replace(/_/g, ' ')}
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span className="mono" style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{entry.actual != null ? `Actual ${entry.actual}` : 'Pending'}
</span>
{entry.hit !== null && (
<span
className="mono"
style={{
fontSize: 12,
fontWeight: 700,
color: entry.hit ? 'var(--grade-a)' : 'var(--grade-d)',
}}
>
{entry.hit ? 'HIT' : 'MISS'}
</span>
)}
</div>
{entry.hit === false && entry.miss_reason && (
<p
style={{
marginTop: 12,
padding: 10,
fontSize: 12,
color: 'var(--grade-d)',
background: 'rgba(255,107,107,0.10)',
borderRadius: 8,
border: '1px solid rgba(255,107,107,0.30)',
}}
>
<strong style={{ fontWeight: 700 }}>Why we missed:</strong> {entry.miss_reason}
</p>
)}
</article>
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' }}>
{rows.map((row, i) => (
<LedgerCard key={row.id} row={row} index={i} />
))}
</div>
)}
@@ -199,25 +160,133 @@ export default function LedgerPage() {
);
}
function AccuracyTile({ bucket, highlight }: { bucket: AccuracyBucket; highlight?: boolean }) {
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
const ready = agg.settled >= minSample && agg.hit_pct != null;
return (
<div
style={{
padding: 16,
borderRadius: 12,
background: highlight ? 'var(--bg-elevated)' : 'var(--bg-surface)',
border: highlight ? '1px solid var(--grade-a)' : '1px solid var(--border)',
}}
className="surface diagonal-cut"
style={{ padding: 20, marginBottom: 20, border: `1px solid ${ready ? 'var(--g-a, #00D4A0)' : 'var(--border)'}` }}
>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
{bucket.tier.toUpperCase()}
{ready ? (
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'baseline' }}>
<span className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)' }}>
MODEL · LAST 30D
</span>
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--g-a, #00D4A0)' }}>
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT
</span>
{agg.beat_close_pct != null && (
<span className="mono" style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>
{agg.beat_close_pct}% BEAT CLOSE
</span>
)}
<span className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{agg.pending} pending
</span>
</div>
) : (
<div>
<p className="mono" style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>
RECORD BUILDING
</p>
<p style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
Every read settles here, misses included.{' '}
<span className="mono" style={{ color: 'var(--text-primary)' }}>
{agg.pending} read{agg.pending === 1 ? '' : 's'} pending settlement
</span>
{agg.settled > 0 && (
<span className="mono" style={{ color: 'var(--text-tertiary)' }}> · {agg.settled} settled</span>
)}
</p>
</div>
)}
</div>
);
}
function OutcomeChip({ row }: { row: LedgerRow }) {
if (!row.outcome) {
return <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>PENDING</span>;
}
const color = row.outcome === 'hit' ? 'var(--g-a, #00D4A0)'
: row.outcome === 'miss' ? 'var(--miss, #FF6B6B)' : 'var(--text-secondary)';
const mark = row.outcome === 'hit' ? '✓ HIT' : row.outcome === 'miss' ? '✕ MISS' : ' PUSH';
return (
<span className="mono" style={{ fontSize: 12, fontWeight: 700, color }}>
{mark}{row.actual_value != null ? ` (${row.actual_value})` : ''}
</span>
);
}
function ClvChip({ row }: { row: LedgerRow }) {
if (!row.clv_result || row.clv == null) return null;
const color = row.clv_result === 'beat' ? 'var(--g-a, #00D4A0)'
: row.clv_result === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)';
return (
<span className="mono" title={`Closing line value: locked ${row.line}, closed ${row.closing_line}`}
style={{ fontSize: 10.5, fontWeight: 700, color, letterSpacing: '0.04em' }}>
CLV {row.clv > 0 ? '+' : ''}{row.clv} · {row.clv_result.toUpperCase()}
</span>
);
}
function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
const sportColor = SPORT_COLOR[row.sport] || 'var(--text-secondary)';
return (
<article className={`surface diagonal-cut animate-fade-up stagger-${(index % 6) + 1}`} style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<span className="mono" style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: `${sportColor}1F`, color: sportColor }}>
{row.sport.toUpperCase()}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{/* Phase 2.5 — a revised grade is public, never silent. */}
{row.revised_from_grade && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
{row.revised_from_grade}
</span>
)}
<GradePill grade={row.grade} />
</span>
</div>
<div className="mono" style={{ fontSize: 24, fontWeight: 800, color: highlight ? 'var(--grade-a)' : 'var(--text-primary)', marginTop: 4 }}>
{bucket.pct}%
</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }}>
{bucket.hits}-{bucket.losses}
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
</p>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 12 }}>
{row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date}
{/* model_value is MODEL output — always labeled, never blended with market numbers. */}
{row.model_value != null && <span> · MODEL {row.model_value}</span>}
</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
<OutcomeChip row={row} />
<ClvChip row={row} />
</div>
</article>
);
}
function EmptyLedger({ tab }: { tab: Tab }) {
return (
<div className="surface diagonal-cut tex-scan" style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
{tab === 'mine' ? (
<>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No reads yet.</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
Read your first prop to start building your Ledger. Every grade you run lands here the moment it completes and settles against the real result.
</p>
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
Read a Prop
</a>
</>
) : (
<>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>The model record starts with the next pipeline run.</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
Every pre-graded prop lands here hits, misses, pushes, and closing-line value. Nothing is deleted.
</p>
</>
)}
</div>
);
}
+7 -1
View File
@@ -4,7 +4,7 @@ import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import Hero from '@/components/Hero';
import { ClaimMeter } from '@/components/vyndr';
import { ClaimMeter, ModelRecord } from '@/components/vyndr';
// Session 55 — live top A-rated grades pulled from tonight's real snapshot,
// with the self-learning loop's accuracy line. The product shown, not described.
import TopSignals from '@/components/TopSignals';
@@ -50,6 +50,12 @@ export default function Home() {
<Hero />
{/* Session 55 — tonight's real top signals + live accuracy (the system works). */}
<TopSignals />
{/* Session 58 (work-order 1.4) — the public model record (proof-strip
footer). Deferred-render: "RECORD BUILDING" until 20 settles, then
the real hit% + beat-close%. Self-hides with no data. */}
<div style={{ padding: '4px 16px 12px' }}>
<ModelRecord />
</div>
{/* Founder-seat scarcity meter (§12) */}
<div style={{ padding: '0 16px 8px' }}>
<ClaimMeter />
+7
View File
@@ -6,6 +6,7 @@ import SportBadge from '@/components/vyndr/SportBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
import ModelRecord from '@/components/vyndr/ModelRecord';
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
@@ -93,6 +94,12 @@ export default function PlayerProfilePage() {
{p.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{p.team}</span>}
<span className="mono" style={{ fontSize: 12, color: 'var(--text-2)' }}>{sportLabel(p.sport)}</span>
</div>
{/* Session 58 (work-order 1.4/§11) — VYNDR-on-player: the model's
settled record on THIS player. Deferred-render; self-hides
until the ledger has rows for them. */}
<div style={{ marginTop: 10 }}>
<ModelRecord player={p.player} sport={p.sport} align="left" />
</div>
{p.archetype?.blend?.length > 0 && (
<div style={{ marginTop: 15 }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
+27 -2
View File
@@ -37,6 +37,8 @@ interface Player {
interface ScanResponse {
grade: string;
// Session 58 (work-order 1.5) — the model refused: no projection, no read.
insufficient_data?: boolean;
projection?: number;
confidence?: number;
sample_size?: number;
@@ -266,7 +268,8 @@ export default function ScanPage() {
return;
}
setResult(data);
bumpScanCount();
// Session 58 — a refused read (insufficient data) doesn't burn a scan.
if (!data.insufficient_data) bumpScanCount();
trackScanCompleted({
sport,
player: selectedPlayer,
@@ -689,9 +692,31 @@ export default function ScanPage() {
</div>
)}
{/* Session 58 (work-order 1.5) — the honest refusal. When the model has
no projection there is NO read: no grade letter, no fake +0% edge,
and nothing writes to the ledger. A refused read builds more trust
than a hollow one. */}
{result && result.insufficient_data && (
<div
className="surface scanlines"
style={{ marginTop: 32, padding: 32, textAlign: 'center', border: '1px solid var(--border-hi)', borderRadius: 10, display: 'grid', gap: 10, justifyItems: 'center' }}
>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--amber)' }}>
INSUFFICIENT DATA NO READ
</p>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>
The model has no projection for this prop, so it refuses to grade it.
No number gets invented here that&apos;s the deal.
</p>
<button onClick={reset} className="btn-ghost" style={{ marginTop: 6, padding: '10px 18px' }}>
Read another prop
</button>
</div>
)}
{/* Grade result — VYNDR 2.0 ProcessingGrade → GradeResultCard (Session 35).
Engine output is mapped to the §7 contract and tier-gated by the adapter. */}
{result && (
{result && !result.insufficient_data && (
<div style={{ marginTop: 32, display: 'grid', gap: 16 }}>
<ProcessingGrade
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
+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');
}
+15 -6
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 },
@@ -44,7 +48,12 @@ const MORE_ITEMS = [
];
// Auth flows own the full screen — no app chrome.
const HIDE_ON = new Set(['/login', '/signup', '/auth/callback', '/']);
// Session 59 (work-order 3.1) — '/' REMOVED from this set. Hiding the bar on
// the landing left anonymous phones with ZERO navigation (the desktop links
// hide <768px and the hamburger was retired in S37) — the audit's vanished-
// nav-at-390px finding. The tab bar is the only mobile nav; it shows
// everywhere except true auth flows.
const HIDE_ON = new Set(['/login', '/signup', '/auth/callback']);
function isActive(pathname: string, href?: string) {
if (!href) return false;
@@ -200,10 +209,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>
+45 -5
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
@@ -186,7 +187,9 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
time: formatGameTime(g.gameTime),
venue: g.venue,
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex),
// Session 59 (work-order 1.6) — pass the game's participants so the join
// guard can drop bad feed rows (a player whose real team isn't in this game).
playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }),
// Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
@@ -358,12 +361,37 @@ export interface SlateProps {
tier?: Tier;
/** Session 49 — user's preferred books (highlighted in each card's lines). */
preferredBooks?: string[];
/** Session 59 (2.1) — the sport tabs are THE filter; parents (dashboard
* legacy sections) subscribe instead of running their own tab row. */
onTabChange?: (tab: SlateTab) => void;
}
export default function Slate({ initialTab = 'all', tier = 'free', preferredBooks }: SlateProps) {
const VALID_TABS = new Set<SlateTab>(['all', 'nba', 'wnba', 'mlb', 'soccer']);
/** ?sport= from the URL (deep-linkable tabs, spec §6 SportTabs). */
function tabFromUrl(): SlateTab | null {
if (typeof window === 'undefined') return null;
const q = new URLSearchParams(window.location.search).get('sport');
const t = String(q || '').toLowerCase() as SlateTab;
return VALID_TABS.has(t) ? t : null;
}
export default function Slate({ initialTab = 'all', tier = 'free', preferredBooks, onTabChange }: SlateProps) {
const router = useRouter();
const { session } = useAuth();
const [tab, setTab] = useState<SlateTab>(initialTab);
// Session 59 (2.1) — the URL is the source of truth on load (?sport=mlb
// deep-links a filtered slate); user prefs are the fallback default.
const [tab, setTabState] = useState<SlateTab>(() => tabFromUrl() || initialTab);
const setTab = (t: SlateTab) => {
setTabState(t);
if (typeof window !== 'undefined') {
const url = new URL(window.location.href);
if (t === 'all') url.searchParams.delete('sport');
else url.searchParams.set('sport', t);
window.history.replaceState(null, '', url.toString());
}
if (onTabChange) onTabChange(t);
};
// Session 23 — active stat category for the intelligence panels. 'all'
// shows everything; selecting one narrows streaks + hot list. Schedule
// and game lines stay visible regardless (handled inside GameCard).
@@ -606,7 +634,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 +835,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>
);
}
+82 -6
View File
@@ -1,5 +1,6 @@
'use client';
import { useMemo, useState } from 'react';
import SportBadge from '@/components/vyndr/SportBadge';
import SectionHead from '@/components/vyndr/SectionHead';
import GradeBadge from '@/components/vyndr/GradeBadge';
@@ -68,6 +69,34 @@ interface GameCardProps {
preferredBooks?: string[];
}
/* Session 59 (work-order 2.2) cards were dumping entire rosters inline.
Strips sort by their best grade (A+ F) and the card shows at most
MAX_VISIBLE_PROPS graded props; the rest sit behind "ALL N READS →",
expanding in place. */
const MAX_VISIBLE_PROPS = 6;
const GRADE_RANK: Record<string, number> = {
'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10,
};
const gradeRank = (g?: string | null) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99);
const stripBestRank = (ps: PlayerStrip) => Math.min(99, ...ps.props.map((p) => gradeRank(p.grade)));
const stripGradedCount = (ps: PlayerStrip) => ps.props.filter((p) => p.grade).length;
/** Sort strips best-grade-first and cut at the prop budget. Always shows at
* least one strip; ungraded-only strips sort last. */
function collapseStrips(strips: PlayerStrip[]) {
const sorted = [...strips].sort((a, b) => stripBestRank(a) - stripBestRank(b));
const totalReads = sorted.reduce((n, s) => n + stripGradedCount(s), 0);
const visible: PlayerStrip[] = [];
let budget = MAX_VISIBLE_PROPS;
for (const s of sorted) {
const cost = Math.max(1, stripGradedCount(s));
if (visible.length > 0 && budget - cost < 0) break;
visible.push(s);
budget -= cost;
}
return { sorted, visible, totalReads, truncated: visible.length < sorted.length };
}
/** Clickable team abbreviation /team/:abbr (Session 51). Stops propagation so
* it doesn't trigger the card's open-game handler; green underline on hover. */
function TeamLink({ abbr, sport }: { abbr: string; sport: string }) {
@@ -141,6 +170,15 @@ function PropRow({ prop: p, onAddParlay }: { prop: GameProp; onAddParlay?: (p: G
export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks }: GameCardProps) {
// Session 50 — Parlay Lab "+" wiring. Builds a leg from the strip + game.
const { addLeg, removeLeg, legs, hasLeg } = useParlay();
// Session 59 — 2.2 card collapse + 3.2 mobile book-table expander.
const [showAllReads, setShowAllReads] = useState(false);
const [linesExpanded, setLinesExpanded] = useState(false);
const collapsed = useMemo(() => collapseStrips(g.playerStrips || []), [g.playerStrips]);
const stripsToRender = showAllReads ? collapsed.sorted : collapsed.visible;
const bestLine = (pick: (ln: GameLine) => boolean, val: (ln: GameLine) => string) => {
const ln = (g.lines || []).find(pick);
return ln ? val(ln) : (g.lines && g.lines[0] ? val(g.lines[0]) : '—');
};
const sportU = (g.sport || 'nba').toUpperCase();
const gameId = `${g.away.abbr} @ ${g.home.abbr}`;
const toLeg = (ps: PlayerStrip, p: StripProp) => ({
@@ -184,11 +222,12 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
)}
</div>
{/* SUB-HEADER */}
{/* SUB-HEADER Session 59 (3.2): venue drops FIRST on mobile
(.gc-venue is display:none <640px) so time + pitchers never truncate. */}
<div className="mono" style={{ padding: '0 16px 11px', fontSize: 11.5, color: 'var(--text-1)' }}>
{g.away.name} <span style={{ color: 'var(--text-2)' }}>·</span> {g.home.name}
<span style={{ color: 'var(--text-2)' }}> · </span>{g.time}
{g.venue && (<><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</>)}
<span style={{ color: 'var(--text-2)' }}> · </span><span style={{ whiteSpace: 'nowrap' }}>{g.time}</span>
{g.venue && (<span className="gc-venue"><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</span>)}
</div>
{/* MLB STARTING PITCHERS (Session 42) */}
@@ -209,11 +248,29 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
<div style={{ height: 1, background: 'var(--border)' }} />
{/* GAME LINES */}
{/* GAME LINES Session 59 (3.2): below 640px the 8-book table collapses
to a best-line summary row with an "N BOOKS ⌄" expander; desktop
always shows the full table. All values are real book numbers. */}
{g.lines && g.lines.length > 0 && (
<div style={{ padding: '13px 16px' }}>
<SectionHead style={{ marginBottom: 11 }}>GAME LINES <span style={{ color: 'var(--text-2)' }}>· {g.lines.length} BOOKS</span></SectionHead>
<div style={{ display: 'grid', gridTemplateColumns: '78px 1fr 1fr 1fr', gap: '2px 4px', alignItems: 'center' }}>
{/* Mobile summary (hidden ≥640px via .gl-summary) */}
<div className="gl-summary mono" style={{ alignItems: 'center', gap: 10, fontSize: 12 }}>
<span><span className="label" style={{ fontSize: 9.5 }}>{g.away.abbr}</span> <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{bestLine((l) => !!l.bestAway, (l) => l.awayML)}</span></span>
<span><span className="label" style={{ fontSize: 9.5 }}>{g.home.abbr}</span> <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{bestLine((l) => !!l.bestHome, (l) => l.homeML)}</span></span>
<span><span className="label" style={{ fontSize: 9.5 }}>O/U</span> <span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{bestLine((l) => !!l.bestOU, (l) => l.ou)}</span></span>
<button
onClick={(e) => { e.stopPropagation(); setLinesExpanded((v) => !v); }}
className="mono"
aria-expanded={linesExpanded}
style={{ marginLeft: 'auto', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, color: 'var(--text-1)', fontSize: 10.5, fontWeight: 700, letterSpacing: '0.06em', padding: '5px 9px', cursor: 'pointer', minHeight: 30 }}
>
{g.lines.length} BOOKS {linesExpanded ? '⌃' : '⌄'}
</button>
</div>
<div className={`gl-full ${linesExpanded ? 'gl-expanded' : ''} game-lines-grid`} style={{ display: 'grid', gridTemplateColumns: '78px 1fr 1fr 1fr', gap: '2px 4px', alignItems: 'center' }}>
<div className="label" style={{ fontSize: 10 }}>BOOK</div>
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{g.away.abbr} ML</div>
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{g.home.abbr} ML</div>
@@ -239,7 +296,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
<SectionHead style={{ marginBottom: 11 }}>GRADED PROPS</SectionHead>
{g.playerStrips && g.playerStrips.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{g.playerStrips.map((ps, i) => (
{stripsToRender.map((ps, i) => (
<StatStrip
key={i}
player={ps.player}
@@ -253,6 +310,25 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
{...stripHandlers(ps)}
/>
))}
{/* Session 59 (2.2) — the rest of the roster expands in place. */}
{collapsed.truncated && !showAllReads && (
<button
onClick={(e) => { e.stopPropagation(); setShowAllReads(true); }}
className="mono"
style={{ alignSelf: 'flex-start', background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--g-a)', fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', padding: '6px 2px' }}
>
ALL {collapsed.totalReads} READS
</button>
)}
{collapsed.truncated && showAllReads && (
<button
onClick={(e) => { e.stopPropagation(); setShowAllReads(false); }}
className="mono"
style={{ alignSelf: 'flex-start', background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-1)', fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', padding: '6px 2px' }}
>
COLLAPSE
</button>
)}
</div>
) : g.props && g.props.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
+14 -7
View File
@@ -18,8 +18,8 @@ export interface GradeResultData {
side: 'Over' | 'Under';
grade: string;
confidence: number;
edge: number;
projection: number;
edge: number | null;
projection: number | null;
phosphorConfirmed?: boolean;
signals: string[];
killConditions?: string[];
@@ -145,8 +145,12 @@ export default function GradeResultCard({
{/* 3. CONFIDENCE STRIP */}
<div className="mono" style={{ position: 'relative', zIndex: 2, marginTop: 8, fontSize: 15, fontWeight: 600, color: 'var(--text-0)', display: 'flex', justifyContent: 'center', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ color: hex, fontWeight: 800 }}>{d.grade}</span>
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
<span style={{ color: 'var(--g-a)' }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
{d.edge != null && (
<>
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
<span style={{ color: 'var(--g-a)' }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
</>
)}
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
<span>{d.confidence}% confidence</span>
</div>
@@ -159,12 +163,15 @@ export default function GradeResultCard({
)}
</div>
{/* 4. PROJECTION ROW */}
{/* 4. PROJECTION ROW DATA SEMANTICS (Session 58): LINE is a real
market number (fact); MODEL/EDGE are model output. When the model
has no projection they render an absent state ("—"), never the
line or a fake +0%. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', borderBottom: '1px solid var(--border)' }}>
{[
{ l: 'MODEL', v: d.projection, col: 'var(--g-a)' },
{ l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' },
{ l: 'LINE', v: d.line, col: 'var(--text-0)' },
{ l: 'EDGE', v: `${d.edge >= 0 ? '+' : ''}${d.edge}%`, col: 'var(--g-a)' },
{ l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? 'var(--g-a)' : 'var(--text-2)' },
].map((x, i) => (
<div key={i} style={{ padding: '14px 16px', textAlign: 'center', borderRight: i < 2 ? '1px solid var(--border)' : 'none' }}>
<div className="label" style={{ fontSize: 10, marginBottom: 5 }}>{x.l}</div>
+72 -9
View File
@@ -39,11 +39,73 @@ 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;
/** The pipeline's expected cadence (s) — SYNC thresholds key off this. */
expected_interval_s?: number;
}
const SUMMARY_POLL_MS = 60_000;
// Session 58 (Task 5) — SYNC thresholds are relative to the pipeline's
// EXPECTED interval (SNAPSHOT_EXPECTED_INTERVAL server-side), not a flat 5
// minutes: normal < 1.5x · amber ≥ 1.5x · STALE red ≥ 3x. When Phase 2.5's
// intraday refresh ships, the server drops the interval and this goes
// genuinely live with zero changes here.
const DEFAULT_EXPECTED_S = 18_000; // 5h max cron gap
/** 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, tiered against the expected
* cadence (Session 58). 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,
expected_interval_s: Number(data.expected_interval_s) > 0 ? Number(data.expected_interval_s) : undefined,
});
}
} 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 expectedMs = (summary?.expected_interval_s ?? DEFAULT_EXPECTED_S) * 1000;
const tier: 'normal' | 'amber' | 'stale' =
elapsed === null ? 'normal'
: elapsed >= 3 * expectedMs ? 'stale'
: elapsed >= 1.5 * expectedMs ? 'amber'
: 'normal';
const syncColor = tier === 'stale' ? 'var(--miss)' : tier === 'amber' ? 'var(--amber)' : 'var(--text-2)';
return (
<div
className="scanlines"
@@ -77,13 +139,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: syncColor, fontWeight: tier === 'stale' ? 700 : 400, flexShrink: 0 }}>
{tier === 'stale' ? 'STALE' : '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>
);
}
+91
View File
@@ -0,0 +1,91 @@
'use client';
import { useEffect, useState } from 'react';
/**
* ModelRecord (Session 58, work-order 1.4) the public model record as a
* deferred-render strip/chip. Reads the /api/ledger/model 30d aggregate:
*
* n min_sample "MODEL 30D: H-M · X% HIT [· Y% BEAT CLOSE]" (green)
* settles building "RECORD BUILDING · N reads pending settlement" (amber)
* nothing at all / fetch failed renders NOTHING.
*
* Never a percentage on a small sample; never an invented number. Ship the
* wiring now the numbers appear on their own as settles accumulate.
* Optional `sport` / `player` scope the record (VYNDR-on-player, §11).
*/
interface Aggregate {
settled: number;
hits: number;
misses: number;
hit_pct: number | null;
beat_close_pct: number | null;
pending: number;
}
export default function ModelRecord({
sport,
player,
align = 'center',
}: {
sport?: string;
player?: string;
align?: 'center' | 'left';
}) {
const [agg, setAgg] = useState<Aggregate | null>(null);
const [minSample, setMinSample] = useState(20);
useEffect(() => {
let active = true;
const params = new URLSearchParams({ limit: '1' });
if (sport) params.set('sport', sport.toLowerCase());
if (player) params.set('player', player);
fetch(`/api/ledger/model?${params}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!active || !data?.aggregate) return;
setAgg(data.aggregate);
if (Number(data.min_sample) > 0) setMinSample(Number(data.min_sample));
})
.catch(() => { /* self-hide */ });
return () => { active = false; };
}, [sport, player]);
if (!agg) return null;
const ready = agg.settled >= minSample && agg.hit_pct != null;
if (!ready && agg.pending === 0 && agg.settled === 0) return null; // nothing to claim yet
return (
<div
className="mono"
style={{
display: 'flex',
justifyContent: align === 'center' ? 'center' : 'flex-start',
alignItems: 'baseline',
gap: 12,
flexWrap: 'wrap',
fontSize: 11.5,
letterSpacing: '0.06em',
}}
>
{ready ? (
<>
<span style={{ color: 'var(--text-2, #6A6A7A)', fontWeight: 700 }}>MODEL 30D</span>
<span style={{ color: 'var(--g-a, #00D4A0)', fontWeight: 800 }}>
{agg.hits}-{agg.misses} · {agg.hit_pct}% HIT
</span>
{agg.beat_close_pct != null && (
<span style={{ color: 'var(--text-0, #EDEDF2)', fontWeight: 700 }}>
{agg.beat_close_pct}% BEAT CLOSE
</span>
)}
</>
) : (
<span style={{ color: 'var(--amber, #FFB347)', fontWeight: 700 }}>
RECORD BUILDING · {agg.pending} read{agg.pending === 1 ? '' : 's'} pending settlement
</span>
)}
</div>
);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import { nextRunLabelET } from '@/lib/pipelineSchedule';
export interface StatCell {
label: string;
@@ -216,10 +217,13 @@ export default function StatStrip({
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{props.map((p, i) => {
if (p.awaiting) {
// Session 59 (work-order 2.3) — the honest waiting state: the
// real next pipeline run, not passive mystery.
const next = nextRunLabelET();
return (
<div key={i} className="mono" style={{ fontSize: 11, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: 'var(--text-1)' }}>{p.stat} {p.line}</span>
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>Awaiting next scan</span>
<span style={{ color: 'var(--text-2)', fontStyle: 'italic' }}>{next ? `Grades post ${next}` : 'Awaiting next scan'}</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
+1
View File
@@ -15,6 +15,7 @@ export { default as GameCard } from './GameCard';
export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } from './GameCard';
export { default as ClaimMeter } from './ClaimMeter';
export { default as AccuracyBadge } from './AccuracyBadge';
export { default as ModelRecord } from './ModelRecord';
/* Player Intelligence (Session 42) */
export { default as ArchetypeBadge } from './ArchetypeBadge';
+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 };
+7 -2
View File
@@ -77,8 +77,13 @@ function mapScanToGradeResult(input = {}) {
side,
grade: input.grade || '—',
confidence,
edge: computeEdge(projection, line, direction),
projection: projection != null ? Math.round(projection * 10) / 10 : line,
// DATA SEMANTICS (Session 58): projection is MODEL output and must never
// be fabricated. The old fallback displayed the LINE as the projection —
// the audit's model==line / +0% edge degenerate. No projection → the
// MODEL row is absent and edge is null (the card renders an absent state,
// not a fake zero-edge read).
edge: projection != null ? computeEdge(projection, line, direction) : null,
projection: projection != null ? Math.round(projection * 10) / 10 : null,
phosphorConfirmed: isPhosphorConfirmed(input.grade, input.confidence, input.sample_size),
signals,
killConditions,
+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;
},
+40
View File
@@ -0,0 +1,40 @@
/* ============================================================
Session 59 (work-order 2.3) the REAL pipeline schedule, for honest
waiting states. "Awaiting next scan" told the user nothing; the truth is
the cron fires at fixed UTC hours, so we can say "Grades post ~6:00 PM ET".
HOURS_UTC mirrors the backend default (snapshotScheduler SNAPSHOT_HOURS_UTC
= 14,19,22,1,3). If the env schedule changes, update BOTH places a wrong
time here is a lie, which is worse than the old vague copy.
CommonJS so the plain-JS Jest suite can require it directly.
============================================================ */
const HOURS_UTC = [14, 19, 22, 1, 3];
/** The next scheduled run strictly after `now`, as a Date. */
function nextRunAt(now = new Date(), hours = HOURS_UTC) {
const candidates = [];
for (let dayOffset = 0; dayOffset <= 1; dayOffset += 1) {
for (const h of hours) {
const d = new Date(now.getTime());
d.setUTCDate(d.getUTCDate() + dayOffset);
d.setUTCHours(h, 0, 0, 0);
if (d.getTime() > now.getTime()) candidates.push(d);
}
}
candidates.sort((a, b) => a.getTime() - b.getTime());
return candidates[0] || null;
}
/** "~6:00 PM ET" for the next run. Empty string when the schedule is unknown. */
function nextRunLabelET(now = new Date(), hours = HOURS_UTC) {
const next = nextRunAt(now, hours);
if (!next) return '';
const t = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit',
}).format(next);
return `~${t} ET`;
}
module.exports = { HOURS_UTC, nextRunAt, nextRunLabelET };
+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',
+31 -2
View File
@@ -241,14 +241,37 @@ function gradedAgo(iso, now = Date.now()) {
return `${Math.round(hrs / 24)}d ago`;
}
/** Team-identity match by nickname token (last word) "New York Yankees"
* "Yankees"; exact string match also accepted. */
function slateTeamsMatch(a, b) {
if (!a || !b) return false;
const sa = String(a).toLowerCase(), sb = String(b).toLowerCase();
if (sa === sb) return true;
return teamMascot(a) !== '' && teamMascot(a) === teamMascot(b);
}
/**
* Build pre-graded `playerStrips` for one game by OVERLAYING the snapshot's
* locked grades onto the game's odds-derived props (which already carry the
* correct game grouping). Each prop is either graded (grade + gradedAt + delta)
* or `awaiting:true` (no snapshot match yet "Awaiting next scan", no Read
* button). Archetype comes from the snapshot's per-player classification.
*
* Session 59 (work-order 1.6) THE JOIN INVARIANT: when the snapshot knows
* the player's REAL team (grades carry `team` from the stats resolve) and
* the caller passes the game's participants (`gameTeams`), a prop whose
* player does NOT belong to either team is DROPPED from the card entirely
* a bad feed row must not render a TB player under MIL@PIT. Props without
* team info are kept (can't verify wrong).
*/
function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now()) {
/**
* @param {Array<object>} gameProps
* @param {Record<string, any>} gradeIndex
* @param {Record<string, any>} deltaIndex
* @param {number} [now]
* @param {{home?: string, away?: string} | null} [gameTeams]
*/
function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now(), gameTeams = null) {
const byPlayer = {};
const order = [];
for (const p of gameProps || []) {
@@ -257,10 +280,16 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
// / "AJ Ewing") merge into ONE strip; display the longest seen variant.
const pk = nameKey(p.player);
const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)];
// Join guard: known player team that isn't in this game → bad row, drop.
const knownTeam = (rec && rec.team) || p.team || '';
if (knownTeam && gameTeams && (gameTeams.home || gameTeams.away)) {
const inGame = slateTeamsMatch(knownTeam, gameTeams.home) || slateTeamsMatch(knownTeam, gameTeams.away);
if (!inGame) continue;
}
if (!byPlayer[pk]) {
byPlayer[pk] = {
player: displayName(p.player),
team: p.team || '',
team: knownTeam,
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
stats: [],
props: [],