Compare commits
5 Commits
2ae8a5697e
...
d10bb4cce2
| Author | SHA1 | Date | |
|---|---|---|---|
| d10bb4cce2 | |||
| c96e74c54b | |||
| d296e40cb6 | |||
| 2c79373a3b | |||
| c8790fde55 |
+128
-3
@@ -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 = locked−closing),
|
||||
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 1–6 queued).
|
||||
|
||||
### 0.1 — Fake NBA game (root cause found + fixed at the source)
|
||||
- `scheduleService.fetchScheduleFromEspn` hit ESPN with NO `?dates=` param and
|
||||
NO date filter → off-season ESPN returns the NEAREST slate (the Jun 13 NYK@SA
|
||||
Finals game rendered as "tonight" all July). Now pins `?dates=YYYYMMDD` AND
|
||||
filters events to the requested ET date; undated events dropped.
|
||||
- Honest per-sport empty states: `web/src/lib/emptyState.js` (month-aware —
|
||||
"NBA returns in October." vs in-season "No NBA games today."), used by the
|
||||
dashboard NO SLATE block + the Slate empty state.
|
||||
|
||||
### 0.2 — Fake header counters deleted
|
||||
- `liveTick.js` stripped to `{ tick }` (1s re-render pulse only). The fake
|
||||
auto-incrementing "247 graded", sin-driven brain-%, aPlus/cascades: DEAD.
|
||||
- New `GET /api/snapshot/summary` (public, cache-only, registered before
|
||||
`/:sport`) → `{ graded, updated_at, sports }`. Next proxy added.
|
||||
- `HeartbeatBar` shows the REAL graded count + SYNC = elapsed since the last
|
||||
pipeline run (amber past 5 min). No data → "SYNC —", nothing invented.
|
||||
|
||||
### 0.3 — Ticker: real items only
|
||||
- Nav's hardcoded fallback items (fake Wembanyama A+/"NYK vs SA Q3"/invented
|
||||
Tatum MOVE) deleted. MOVE stays — `computeLineDeltas` is real movement.
|
||||
- Spec §3 rule: <4 real items → ticker renders nothing. It publishes
|
||||
`--ticker-h` (32px/0px) on `<html>`; layout main paddingTop and Slate sticky
|
||||
top are now `calc(base + var(--ticker-h))` so the header collapses cleanly.
|
||||
|
||||
### 0.4 — /terminal retired (fabricated surface)
|
||||
- Route now `redirect('/dashboard')`. Layouts preserved UNROUTED at
|
||||
`web/src/components/intel/TerminalTemplates.tsx` (§12 content-engine
|
||||
templates — feed real data before ever routing them).
|
||||
- Nav PRIMARY = Slate/Scan/Ledger. BottomTabBar: Terminal→Explore. PWA
|
||||
manifest shortcut Terminal→Ledger. routes.js: `/terminal` out of
|
||||
OPEN_ROUTES; `#terminal` → `/dashboard`.
|
||||
|
||||
### 0.5 — ›Query nav pill deleted (duplicate /scan link)
|
||||
|
||||
### Next (work order Phase 1 — starts the settle clock)
|
||||
- `ledger_entries` table + write-on-grade (user scans AND pipeline pre-grades)
|
||||
- Settlement worker; populate /ledger (personal + MODEL tabs)
|
||||
- Degenerate scan output (model==line, +0% edge) → honest "INSUFFICIENT DATA"
|
||||
- Canonical player_key normalization (blocks 2.4, 4.1)
|
||||
|
||||
## Session 56 (2026-07-10) — SHIPPED ✅ THE FULL AUDIT
|
||||
|
||||
|
||||
@@ -684,6 +684,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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -60,6 +60,52 @@ build-out target.
|
||||
|
||||
Scope key: S ≈ ½ session, M ≈ 1 session, L ≈ 1–2 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 (~noon–midnight ET):
|
||||
|
||||
- Lightweight ODDS-ONLY refresh every 15–30 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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -28,12 +28,16 @@ function mountApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
// NOTE (Session 57, Phase 0): the event date must fall on the REQUESTED ET
|
||||
// date (2026-06-12) — 00:40Z Jun 13 = 20:40 ET Jun 12. The old fixture was
|
||||
// dated a day later and the route served it anyway; that exact hole is how the
|
||||
// Jun 13 NYK@SA Finals game kept rendering as "tonight" all July.
|
||||
const ESPN_NBA = {
|
||||
data: {
|
||||
events: [
|
||||
{
|
||||
id: '401234567',
|
||||
date: '2026-06-14T00:40:00Z',
|
||||
date: '2026-06-13T00:40:00Z',
|
||||
status: { type: { state: 'pre' } },
|
||||
competitions: [{
|
||||
venue: { fullName: 'Frost Bank Center' },
|
||||
@@ -81,6 +85,57 @@ describe('GET /api/schedule/:sport', () => {
|
||||
expect(axios.get.mock.calls.length).toBe(callsAfterFirst); // served from cache
|
||||
});
|
||||
|
||||
test('pins the ESPN fetch to the requested date (?dates=YYYYMMDD)', async () => {
|
||||
axios.get.mockResolvedValue(ESPN_NBA);
|
||||
await request(mountApp()).get('/api/schedule/nba?date=2026-06-12');
|
||||
expect(axios.get.mock.calls[0][0]).toContain('dates=20260612');
|
||||
});
|
||||
|
||||
// Session 57 (Phase 0) regression — in the off-season ESPN returns the
|
||||
// NEAREST slate, not today's. Any event whose ET date ≠ the requested date
|
||||
// must be dropped, or a June Finals game renders as tonight's slate in July.
|
||||
test('filters out events from a different ET date (fake off-season game)', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: {
|
||||
events: [
|
||||
{
|
||||
id: '401999999',
|
||||
date: '2026-06-14T00:30:00Z', // Jun 13 ET — NOT the requested Jun 12
|
||||
status: { type: { state: 'pre' } },
|
||||
competitions: [{
|
||||
competitors: [
|
||||
{ homeAway: 'home', score: '0', team: { displayName: 'San Antonio Spurs', abbreviation: 'SA' } },
|
||||
{ homeAway: 'away', score: '0', team: { displayName: 'New York Knicks', abbreviation: 'NYK' } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.games).toEqual([]);
|
||||
});
|
||||
|
||||
test('drops events with no parseable date (live feed only)', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
data: {
|
||||
events: [{
|
||||
id: '77', // no `date` field at all
|
||||
status: { type: { state: 'pre' } },
|
||||
competitions: [{
|
||||
competitors: [
|
||||
{ homeAway: 'home', score: '0', team: { displayName: 'A', abbreviation: 'A' } },
|
||||
{ homeAway: 'away', score: '0', team: { displayName: 'B', abbreviation: 'B' } },
|
||||
],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
});
|
||||
const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12');
|
||||
expect(res.body.games).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns empty array (not error) when no games today', async () => {
|
||||
axios.get.mockResolvedValue({ data: { events: [] } });
|
||||
const res = await request(mountApp()).get('/api/schedule/mlb?date=2026-06-12');
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Session 57 (Phase 0) — GET /api/snapshot/summary: the HeartbeatBar's honest
|
||||
// data source. Real graded counts + latest pipeline run time from cache-only
|
||||
// Redis reads; must never invent numbers on a cold cache.
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const store = {};
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: jest.fn(async (k) => (k in store ? store[k] : null)),
|
||||
cacheSet: jest.fn(async (k, v) => { store[k] = v; return true; }),
|
||||
}));
|
||||
|
||||
function mountApp() {
|
||||
delete require.cache[require.resolve('../../src/routes/snapshot')];
|
||||
const snapshotRoutes = require('../../src/routes/snapshot');
|
||||
const app = express();
|
||||
app.use('/api/snapshot', snapshotRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(store)) delete store[k];
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/snapshot/summary', () => {
|
||||
test('sums real graded counts across sports and reports the latest run', async () => {
|
||||
store['snapshot:mlb:latest'] = {
|
||||
updated_at: '2026-07-10T18:00:00.000Z',
|
||||
grades: [{ player: 'A' }, { player: 'B' }, { player: 'C' }],
|
||||
};
|
||||
store['snapshot:wnba:latest'] = {
|
||||
updated_at: '2026-07-10T19:00:00.000Z',
|
||||
grades: [{ player: 'D' }],
|
||||
};
|
||||
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.graded).toBe(4);
|
||||
expect(res.body.updated_at).toBe('2026-07-10T19:00:00.000Z'); // latest run
|
||||
expect(res.body.sports.mlb).toBe(3);
|
||||
expect(res.body.sports.wnba).toBe(1);
|
||||
expect(res.body.sports.nba).toBe(0); // off-season → honest zero
|
||||
});
|
||||
|
||||
test('falls back to the grades:{sport} envelope when no snapshot exists', async () => {
|
||||
store['grades:mlb'] = { updated_at: '2026-07-10T16:00:00.000Z', grades: [{ player: 'A' }, { player: 'B' }] };
|
||||
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||
expect(res.body.graded).toBe(2);
|
||||
expect(res.body.updated_at).toBe('2026-07-10T16:00:00.000Z');
|
||||
});
|
||||
|
||||
test('cold cache → zeros and null timestamp, never invented numbers', async () => {
|
||||
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.graded).toBe(0);
|
||||
expect(res.body.updated_at).toBeNull();
|
||||
});
|
||||
|
||||
test('is not captured by the /:sport route (summary ≠ a sport)', async () => {
|
||||
store['snapshot:mlb:latest'] = { updated_at: 'x', grades: [{ player: 'A' }] };
|
||||
const res = await request(mountApp()).get('/api/snapshot/summary');
|
||||
// The /:sport handler would return { sport, grades, deltas }.
|
||||
expect(res.body.sport).toBeUndefined();
|
||||
expect(res.body.grades).toBeUndefined();
|
||||
expect(typeof res.body.graded).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('Phase C — routing config (lib/routes)', () => {
|
||||
});
|
||||
|
||||
it('leaves the landing + free funnel open (dashboard, scan stay public)', () => {
|
||||
['/', '/dashboard', '/scan', '/pricing', '/blog', '/terminal', '/login'].forEach((r) => {
|
||||
['/', '/dashboard', '/scan', '/pricing', '/blog', '/login'].forEach((r) => {
|
||||
expect(routes.isGatedRoute(r)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -37,12 +37,17 @@ describe('Phase C — routing config (lib/routes)', () => {
|
||||
|
||||
it('resolves hash deep-link aliases to real routes', () => {
|
||||
expect(routes.resolveHashAlias('#scan')).toBe('/scan');
|
||||
expect(routes.resolveHashAlias('#terminal')).toBe('/terminal');
|
||||
// Session 57 (Phase 0) — /terminal retired; old links land on the slate.
|
||||
expect(routes.resolveHashAlias('#terminal')).toBe('/dashboard');
|
||||
expect(routes.resolveHashAlias('#slate')).toBe('/dashboard');
|
||||
expect(routes.resolveHashAlias('#nope')).toBeNull();
|
||||
expect(routes.resolveHashAlias('')).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the retired /terminal surface from OPEN_ROUTES', () => {
|
||||
expect(routes.OPEN_ROUTES).not.toContain('/terminal');
|
||||
});
|
||||
|
||||
it('keeps GATED and OPEN route lists disjoint', () => {
|
||||
const overlap = routes.GATED_ROUTES.filter((r) => routes.OPEN_ROUTES.includes(r));
|
||||
expect(overlap).toEqual([]);
|
||||
@@ -77,11 +82,18 @@ describe('Phase C — Nav conversion', () => {
|
||||
expect(src).toContain("fontFamily: 'var(--mono)'");
|
||||
expect(src).toContain("'var(--g-a)'");
|
||||
});
|
||||
it('exposes the primary Slate/Terminal/Scan/Ledger routes', () => {
|
||||
['/dashboard', '/terminal', '/scan', '/ledger'].forEach((href) => {
|
||||
it('exposes the primary Slate/Scan/Ledger routes', () => {
|
||||
['/dashboard', '/scan', '/ledger'].forEach((href) => {
|
||||
expect(src).toContain(href);
|
||||
});
|
||||
});
|
||||
// Session 57 (Phase 0) — Terminal (fabricated surface) and the duplicate
|
||||
// ›Query pill are gone from the nav. Don't re-add either.
|
||||
it('does not link /terminal or render the ›Query duplicate', () => {
|
||||
expect(src).not.toContain("href: '/terminal'");
|
||||
expect(src).not.toContain('data-search-trigger');
|
||||
expect(src).not.toContain('>Query<');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase C — Footer (system voice)', () => {
|
||||
|
||||
@@ -124,16 +124,22 @@ describe('Phase D — ClaimMeter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase D — Terminal page (real intelligence, not a stub)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,9 +54,10 @@ describe('QA.5 — wordmark everywhere', () => {
|
||||
});
|
||||
|
||||
describe('QA.11 — mobile parity (5-tab bar)', () => {
|
||||
it('BottomTabBar declares Slate/Terminal/Scan/Ledger/More', () => {
|
||||
// Session 57 (Phase 0) — Terminal retired; Explore holds its slot.
|
||||
it('BottomTabBar declares Slate/Explore/Scan/Ledger/More', () => {
|
||||
const src = read('components/BottomTabBar.tsx');
|
||||
['Slate', 'Terminal', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`));
|
||||
['Slate', 'Explore', 'Scan', 'Ledger', 'More'].forEach((t) => expect(src).toContain(`'${t}'`));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -130,13 +130,22 @@ describe('Phase G.1 — living-layer tick engine (§8)', () => {
|
||||
liveTick.tick();
|
||||
expect(calls).toBe(2); // unsubscribed
|
||||
});
|
||||
it('advances tick and creeps the graded count, with a fresh state object', () => {
|
||||
it('advances tick with a fresh state object', () => {
|
||||
const first = liveTick.state;
|
||||
for (let i = 0; i < 7; i++) liveTick.tick();
|
||||
expect(liveTick.state.tick).toBe(7);
|
||||
expect(liveTick.state.graded).toBeGreaterThan(first.graded);
|
||||
expect(liveTick.state).not.toBe(first); // new object → React re-renders
|
||||
});
|
||||
// Session 57 (Phase 0) — the fake header counters are dead. The tick engine
|
||||
// must carry NO display data: no auto-incrementing graded count, no
|
||||
// sin-driven neural %. Real numbers come from /api/snapshot/summary only.
|
||||
it('carries no fabricated display counters', () => {
|
||||
liveTick.tick();
|
||||
expect(Object.keys(liveTick.state)).toEqual(['tick']);
|
||||
const src = read('lib/liveTick.js');
|
||||
expect(src).not.toContain('neural');
|
||||
expect(src).not.toContain('247');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase G.4 — checkout (§12)', () => {
|
||||
@@ -155,6 +164,15 @@ describe('Phase G — React glue wired correctly', () => {
|
||||
expect(src).toContain('ekg-track');
|
||||
expect(src).toContain('count-tick'); // LiveNumber pop
|
||||
});
|
||||
// Session 57 (Phase 0) — the heartbeat's numbers are REAL: graded count +
|
||||
// sync age come from /api/snapshot/summary; the fake neural % is gone.
|
||||
it('HeartbeatBar reads /api/snapshot/summary and shows no neural %', () => {
|
||||
const src = read('components/vyndr/LiveLayer.tsx');
|
||||
expect(src).toContain('/api/snapshot/summary');
|
||||
expect(src).not.toContain('neural');
|
||||
expect(src).toContain('SYNC'); // elapsed-since-snapshot clock
|
||||
expect(src).toContain('updated_at');
|
||||
});
|
||||
it('GlobalHosts registers the design globals + applies prefs on mount', () => {
|
||||
const src = read('components/vyndr/GlobalHosts.tsx');
|
||||
expect(src).toContain('window.__prefs');
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"shortcuts": [
|
||||
{ "name": "The Slate", "short_name": "Slate", "url": "/dashboard", "description": "Tonight's games and graded props" },
|
||||
{ "name": "Scan a prop", "short_name": "Scan", "url": "/scan", "description": "Grade any player prop" },
|
||||
{ "name": "The Terminal", "short_name": "Terminal", "url": "/terminal", "description": "League intelligence hub" }
|
||||
{ "name": "The Ledger", "short_name": "Ledger", "url": "/ledger", "description": "Every grade, every result" }
|
||||
],
|
||||
"icons": [
|
||||
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||
|
||||
@@ -0,0 +1,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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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}`}
|
||||
|
||||
@@ -1,206 +1,12 @@
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
|
||||
export const metadata = { title: 'The Terminal' };
|
||||
|
||||
// Sample league-intelligence dataset (§7 shapes). Real wiring to
|
||||
// scheduleService.getGameSummary (injury cascades) + schedule/odds (leaders)
|
||||
// lands in a later session; the screen + contracts are real now.
|
||||
const INJURY_WIRE = [
|
||||
{
|
||||
player: 'Jamal Murray', team: 'DEN', sport: 'nba', status: 'OUT', injury: 'Hamstring', posted: '2h ago',
|
||||
cascade: [
|
||||
{ player: 'Nikola Jokić', stat: 'Assists', delta: '+3.2% usage', grade: 'A+' },
|
||||
{ player: 'Russell Westbrook', stat: 'Points', delta: '+4.1% usage', grade: 'B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Jeremy Sochan', team: 'SA', sport: 'nba', status: 'OUT', injury: 'Ankle sprain', posted: '3h ago',
|
||||
cascade: [
|
||||
{ player: 'Victor Wembanyama', stat: 'Points', delta: '+3.2% usage', grade: 'A' },
|
||||
{ player: 'Devin Vassell', stat: '3PT Made', delta: '+2.0% usage', grade: 'B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
player: 'Mookie Betts', team: 'LAD', sport: 'mlb', status: 'GTD', injury: 'Wrist', posted: '40m ago',
|
||||
cascade: [{ player: 'Shohei Ohtani', stat: 'RBIs', delta: '+1.9% lineup', grade: 'C' }],
|
||||
},
|
||||
];
|
||||
|
||||
const IMPACTED_GAMES = [
|
||||
{ sport: 'nba', match: 'LAL @ SA', time: '10:30 PM ET', vvi: 92, graded: 7, note: 'Sochan OUT spikes Wembanyama usage; LAL pace + 26th-vs-C matchup compound it.', drivers: ['INJURY CASCADE', 'PACE', 'MATCHUP', 'BLOWOUT RISK'] },
|
||||
{ sport: 'mlb', match: 'LAD @ ATL', time: '7:20 PM ET', vvi: 78, graded: 5, note: 'Truist Park boosts LH power; Ohtani platoon edge vs RHP, ATL bullpen on fumes.', drivers: ['PARK FACTOR', 'PLATOON', 'BULLPEN FATIGUE'] },
|
||||
{ sport: 'wnba', match: 'NY @ LV', time: '9:00 PM ET', vvi: 71, graded: 4, note: "Wilson 31% usage vs NY's soft interior; Liberty on a back-to-back.", drivers: ['USAGE', 'MATCHUP', 'REST EDGE'] },
|
||||
];
|
||||
|
||||
const FACTOR_PULSE = [
|
||||
{ count: 11, label: 'Injury cascades', hint: 'props recalibrated', color: 'var(--g-b)' },
|
||||
{ count: 8, label: 'Pace mismatches', hint: 'tempo edges', color: 'var(--g-a)' },
|
||||
{ count: 6, label: 'Park / platoon', hint: 'MLB power spots', color: 'var(--g-a)' },
|
||||
{ count: 5, label: 'Blowout risk', hint: 'minutes-capped', color: 'var(--miss)' },
|
||||
];
|
||||
|
||||
const LEADERS: { key: string; rows: { player: string; team: string; val: string; grade: string; gradeable: boolean }[] }[] = [
|
||||
{
|
||||
key: 'NBA · Points',
|
||||
rows: [
|
||||
{ player: 'Nikola Jokić', team: 'DEN', val: '31.2', grade: 'A+', gradeable: true },
|
||||
{ player: 'Victor Wembanyama', team: 'SA', val: '29.4', grade: 'A', gradeable: true },
|
||||
{ player: 'Anthony Edwards', team: 'MIN', val: '28.4', grade: 'A', gradeable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'MLB · Total Bases',
|
||||
rows: [
|
||||
{ player: 'Shohei Ohtani', team: 'LAD', val: '2.25', grade: 'A', gradeable: true },
|
||||
{ player: 'Aaron Judge', team: 'NYY', val: '2.10', grade: 'B', gradeable: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MATCHUP_EXPLOITS = [
|
||||
{ sport: 'nba', team: 'LAL', rank: '26th', stat: 'Points allowed to C', exploit: 'Wembanyama Points O26.5 · A' },
|
||||
{ sport: 'wnba', team: 'NY', rank: '8th', stat: 'Opp paint FG% allowed', exploit: "A'ja Wilson Points O23.5 · A" },
|
||||
{ sport: 'mlb', team: 'ATL', rank: 'T-4th', stat: 'HR/9 to lefties', exploit: 'Ohtani Total Bases O1.5 · A' },
|
||||
];
|
||||
|
||||
const statusColor = (s: string) =>
|
||||
s === 'OUT' ? 'var(--miss)' : s === 'GTD' || s === 'QUESTIONABLE' ? 'var(--amber)' : 'var(--text-1)';
|
||||
|
||||
function vviColor(v: number) {
|
||||
return v >= 85 ? 'var(--g-ap)' : v >= 75 ? 'var(--g-a)' : v >= 65 ? 'var(--g-b)' : 'var(--g-c)';
|
||||
}
|
||||
|
||||
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Session 57 (Phase 0) — /terminal is retired. The surface was fabricated
|
||||
* sample data (Finals-era NBA content rendered as live intelligence); audit
|
||||
* verdict: REBUILD. Its layouts are preserved as §12 content-engine templates
|
||||
* in components/intel/TerminalTemplates.tsx. "The Terminal" survives as brand
|
||||
* language only (pricing copy). Old links land on the real slate.
|
||||
*/
|
||||
export default function TerminalPage() {
|
||||
return (
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '28px 16px 96px' }}>
|
||||
{/* HEADER */}
|
||||
<header style={{ marginBottom: 22 }}>
|
||||
<SectionHead accent="var(--g-a)">▚ LEAGUE INTELLIGENCE</SectionHead>
|
||||
<h1 className="mono" style={{ fontSize: 30, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 6px' }}>THE TERMINAL</h1>
|
||||
<p className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
|
||||
The context the books hide — injury cascades, volatility, and the spots tonight's slate underprices.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* VVI — IMPACTED GAMES (intel surface) */}
|
||||
<SectionHead style={{ marginBottom: 12 }}>VYNDR VOLATILITY INDEX · MOST-IMPACTED GAMES</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12, marginBottom: 28 }}>
|
||||
{IMPACTED_GAMES.map((g) => (
|
||||
<div key={g.match} className="intel-surface scanlines" style={{ borderRadius: 10, padding: 16, position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport={g.sport} size="sm" />
|
||||
<span className="mono" style={{ fontSize: 15, fontWeight: 800, color: '#e8fff4' }}>{g.match}</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="mono" style={{ fontSize: 26, fontWeight: 800, lineHeight: 1, color: vviColor(g.vvi), textShadow: '0 0 14px currentColor' }}>{g.vvi}</div>
|
||||
<div className="label" style={{ fontSize: 8.5, color: 'rgba(232,255,244,.5)' }}>VVI</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'rgba(232,255,244,.75)', margin: '10px 0', lineHeight: 1.55 }}>{g.note}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{g.drivers.map((d) => (
|
||||
<span key={d} className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', padding: '2px 6px', borderRadius: 3, color: '#bdf5e2', border: '1px solid rgba(0,255,184,.3)', background: 'rgba(0,0,0,.25)' }}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mono" style={{ marginTop: 10, fontSize: 11, color: 'var(--g-a)' }}>{g.graded} graded props · {g.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20 }}>
|
||||
{/* INJURY CASCADES */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>INJURY WIRE · CASCADE ANALYSIS</SectionHead>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{INJURY_WIRE.map((w) => (
|
||||
<div key={w.player} style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport={w.sport} size="sm" />
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{w.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{w.team}</span>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: statusColor(w.status) }}>{w.status}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginBottom: 10 }}>{w.injury} · {w.posted}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{w.cascade.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)' }}>↳</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{c.player}</span>
|
||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)' }}> · {c.stat} <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>▲ {c.delta}</span></span>
|
||||
</span>
|
||||
<GradeBadge grade={c.grade} size="sm" glow />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* FACTOR PULSE */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>FACTOR PULSE · FIRING NOW</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
{FACTOR_PULSE.map((f) => (
|
||||
<div key={f.label} style={card}>
|
||||
<div className="mono" style={{ fontSize: 28, fontWeight: 800, color: f.color, lineHeight: 1 }}>{f.count}</div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, marginTop: 6 }}>{f.label}</div>
|
||||
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2)', marginTop: 2 }}>{f.hint}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GRADEABLE LEADERS */}
|
||||
<div>
|
||||
<SectionHead style={{ marginBottom: 12 }}>GRADEABLE LEADERS</SectionHead>
|
||||
{LEADERS.map((grp) => (
|
||||
<div key={grp.key} style={{ ...card, marginBottom: 10 }}>
|
||||
<div className="label" style={{ marginBottom: 9 }}>{grp.key}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{grp.rows.map((r) => (
|
||||
<div key={r.player} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600 }}>{r.player} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{r.team}</span></span>
|
||||
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)' }}>{r.val}</span>
|
||||
<GradeBadge grade={r.grade} size="sm" glow={r.gradeable} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MATCHUP EXPLOITS */}
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<SectionHead style={{ marginBottom: 12 }}>MATCHUP EXPLOITS · UNDERPRICED SPOTS</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{MATCHUP_EXPLOITS.map((m, i) => (
|
||||
<div key={i} style={card}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<SportBadge sport={m.sport} size="sm" />
|
||||
<span className="mono" style={{ fontSize: 13, fontWeight: 700 }}>{m.team}</span>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--miss)', fontWeight: 700 }}>{m.rank}</span>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', marginBottom: 8 }}>{m.stat}</div>
|
||||
<div className="mono" style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--g-a)' }}>↳ {m.exploit}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* VYNDR 2.0 mobile tab bar (§6, Session 37). The PWA's primary navigation —
|
||||
* 5 tabs: Slate · Terminal · Scan · Ledger · More. Scan is prominent (raised,
|
||||
* 5 tabs: Slate · Explore · Scan · Ledger · More. Scan is prominent (raised,
|
||||
* grade-green) because it's the core action. More opens a bottom sheet with
|
||||
* every secondary route. Hidden ≥768px via globals.css (.mobile-tab-bar).
|
||||
*
|
||||
* Shown for everyone (anon included): Slate/Terminal/Scan are open routes and
|
||||
* Session 57 (Phase 0): the Terminal tab is gone — that surface was fabricated
|
||||
* and now redirects to /dashboard. Explore (real graded-props browser) takes
|
||||
* its slot so the bar keeps 5 tabs.
|
||||
*
|
||||
* Shown for everyone (anon included): Slate/Explore/Scan are open routes and
|
||||
* this is the only mobile nav. Gated routes (Ledger, Account…) bounce through
|
||||
* the client AuthGate when an anon user taps them.
|
||||
*/
|
||||
@@ -24,7 +28,7 @@ type TabDef = {
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: 'slate', label: 'Slate', href: '/dashboard', icon: SlateIcon },
|
||||
{ id: 'terminal', label: 'Terminal', href: '/terminal', icon: TerminalIcon },
|
||||
{ id: 'explore', label: 'Explore', href: '/explore', icon: ExploreIcon },
|
||||
{ id: 'scan', label: 'Scan', href: '/scan', icon: ScanIcon, primary: true },
|
||||
{ id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
|
||||
{ id: 'more', label: 'More', icon: MoreIcon, isSheet: true },
|
||||
@@ -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
@@ -9,13 +9,16 @@ import NotificationBell from '@/components/NotificationBell';
|
||||
// Nav labels are English literals for now; nav-string i18n lands in Phase G
|
||||
// (Session 38) once the locale dictionaries carry slate/terminal/etc. keys.
|
||||
|
||||
// VYNDR 2.0 nav (§6, Session 34). Primary links are SLATE / TERMINAL / SCAN /
|
||||
// LEDGER; everything else lives under a More dropdown. All nav chrome is
|
||||
// VYNDR 2.0 nav (§6, Session 34). Primary links are SLATE / SCAN / LEDGER;
|
||||
// everything else lives under a More dropdown. All nav chrome is
|
||||
// JetBrains Mono, uppercase, 11px — system language, not SaaS sans. Active
|
||||
// route is grade-green (--g-a); a Ticker runs under the bar.
|
||||
// Session 57 (Phase 0) — TERMINAL removed: the surface was fabricated sample
|
||||
// data (audit verdict REBUILD). Its layouts live on as §12 content-engine
|
||||
// templates in components/intel/TerminalTemplates.tsx; "The Terminal" survives
|
||||
// as brand language only. Don't re-add the link until it's fed real data.
|
||||
const PRIMARY = [
|
||||
{ id: 'slate', label: 'Slate', href: '/dashboard' },
|
||||
{ id: 'terminal', label: 'Terminal', href: '/terminal' },
|
||||
{ id: 'scan', label: 'Scan', href: '/scan' },
|
||||
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
|
||||
];
|
||||
@@ -29,12 +32,10 @@ const MORE = [
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const TICKER_ITEMS = [
|
||||
{ tag: 'A+', text: 'Signal detected · Wembanyama Points', glow: true },
|
||||
{ tag: 'LIVE', text: 'NYK vs SA · Q3 4:22', color: 'var(--live)' },
|
||||
{ tag: 'CASCADE', text: 'Murray OUT → Jokic usage', delta: '▲+12%', color: 'var(--g-b)' },
|
||||
{ tag: 'MOVE', text: 'Tatum o27.5 → o26.5', delta: '▼-1.0', color: 'var(--amber)' },
|
||||
];
|
||||
// Session 57 (Phase 0) — the hardcoded fallback ticker items (fake Wembanyama
|
||||
// signal, "NYK vs SA Q3", invented Tatum move) are DELETED. The ticker renders
|
||||
// only real /api/ticker snapshot exhaust, and hides itself entirely below 4
|
||||
// real items (spec §3). Never seed it with fabricated content again.
|
||||
|
||||
function isActive(pathname: string, href: string) {
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
@@ -175,32 +176,10 @@ export default function Nav() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — search, bell, read meter / plan, avatar */}
|
||||
{/* Right — bell, read meter / plan, avatar. Session 57 (Phase 0):
|
||||
the "›Query" pill was a duplicate link to /scan — deleted. A real
|
||||
⌘K palette is a later nicety (spec §4), not a nav link. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<a
|
||||
href="/scan"
|
||||
title="Query the system"
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'none',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
borderRadius: 7,
|
||||
border: '1px solid var(--border-hi)',
|
||||
background: 'var(--bg-2)',
|
||||
color: 'var(--text-1)',
|
||||
textDecoration: 'none',
|
||||
fontSize: 11,
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
data-search-trigger
|
||||
>
|
||||
<span style={{ color: 'var(--g-a)' }}>›</span>
|
||||
<span>Query</span>
|
||||
</a>
|
||||
|
||||
{/* Preferences (language / odds format / accessibility) — §9/§10 */}
|
||||
<button
|
||||
onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()}
|
||||
@@ -408,8 +387,9 @@ export default function Nav() {
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Ticker + heartbeat under the bar (§8 living layer) */}
|
||||
<Ticker items={TICKER_ITEMS} height={32} />
|
||||
{/* Ticker + heartbeat under the bar (§8 living layer). No fallback
|
||||
items — real snapshot exhaust or nothing. */}
|
||||
<Ticker items={[]} height={32} />
|
||||
<HeartbeatBar />
|
||||
|
||||
<style jsx>{`
|
||||
@@ -423,9 +403,6 @@ export default function Nav() {
|
||||
:global(.nav-mobile-panel) {
|
||||
display: none !important;
|
||||
}
|
||||
:global([data-search-trigger]) {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||
import { PropRowProp, Tier } from '@/components/PropRow';
|
||||
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter';
|
||||
import { emptyStateCopy } from '@/lib/emptyState';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
// Session 23 — all-day intelligence layer. The stat filter is the
|
||||
// navigation system; streaks + hot lists layer ON TOP of the odds the
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* ============================================================
|
||||
Session 57 (Phase 0) — honest per-sport empty-slate copy (spec §6).
|
||||
|
||||
A sport with zero games must say WHY instead of showing a fixture game or
|
||||
passive "waiting" language. Month-aware: an off-season sport names its
|
||||
return window; an in-season sport calls it an off-day. CommonJS so both the
|
||||
.tsx surfaces (allowJs) and the plain-JS Jest suite can load it.
|
||||
============================================================ */
|
||||
|
||||
// Off-season months per sport (0-indexed). Deliberately conservative: only
|
||||
// months where the league is CERTAINLY dark, so an early/late season edge day
|
||||
// reads as an off-day rather than falsely claiming the league is done.
|
||||
const OFF_SEASON = {
|
||||
nba: { months: [6, 7, 8], returns: 'October' }, // Jul–Sep
|
||||
wnba: { months: [10, 11, 0, 1, 2, 3], returns: 'May' }, // Nov–Apr
|
||||
mlb: { months: [11, 0, 1], returns: 'spring' }, // Dec–Feb
|
||||
};
|
||||
|
||||
const LABEL = { nba: 'NBA', wnba: 'WNBA', mlb: 'MLB', soccer: 'Soccer' };
|
||||
|
||||
/**
|
||||
* Copy for an empty slate. `sport` is lowercase ('nba'|'wnba'|'mlb'|'soccer'
|
||||
* |'all'); anything else falls back to the generic line.
|
||||
* Returns { title, body }.
|
||||
*/
|
||||
function emptyStateCopy(sport, date = new Date()) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
const month = date.getMonth();
|
||||
|
||||
const off = OFF_SEASON[sp];
|
||||
if (off && off.months.includes(month)) {
|
||||
return {
|
||||
title: `${LABEL[sp]} returns in ${off.returns}.`,
|
||||
body: 'The offseason board is dark. The model is running the live slates in the meantime.',
|
||||
};
|
||||
}
|
||||
if (LABEL[sp]) {
|
||||
return {
|
||||
title: `No ${LABEL[sp]} games today.`,
|
||||
body: 'Off-day. The board refills with the next slate.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'No games on the board today.',
|
||||
body: 'The board refills with the next slate.',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { emptyStateCopy, OFF_SEASON };
|
||||
@@ -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
@@ -1,16 +1,22 @@
|
||||
/* ============================================================
|
||||
VYNDR 2.0 — living-layer tick engine (§8).
|
||||
ONE interval, many subscribers (fan-out). Drives HeartbeatBar,
|
||||
LiveNumber, NeuralBrain. Plain CommonJS so the React hook imports it
|
||||
ONE interval, many subscribers (fan-out). Drives HeartbeatBar's
|
||||
elapsed-since-snapshot clock. Plain CommonJS so the React hook imports it
|
||||
AND Jest drives it manually via tick().
|
||||
|
||||
Does NOT auto-start on import (would run in SSR/tests). The React hook
|
||||
calls start() on mount; tests call tick() directly. Animations are gated
|
||||
behind reduced-motion in the CSS layer (Session 33), not here — the tick
|
||||
only updates data.
|
||||
|
||||
Session 57 (Phase 0) — the fake counters are DEAD. This used to carry a
|
||||
cosmetically auto-incrementing graded count, a sin-driven brain-%, and
|
||||
invented aPlus/cascades counts. All deleted: real numbers now come from
|
||||
/api/snapshot/summary (see LiveLayer). The tick survives ONLY as the 1s
|
||||
re-render pulse. Do not add display data back here.
|
||||
============================================================ */
|
||||
|
||||
const INITIAL = { tick: 0, graded: 247, neural: 96, aPlus: 6, cascades: 11 };
|
||||
const INITIAL = { tick: 0 };
|
||||
|
||||
const liveTick = {
|
||||
listeners: new Set(),
|
||||
@@ -28,17 +34,7 @@ const liveTick = {
|
||||
|
||||
/** Advance one tick. New state object so React subscribers re-render. */
|
||||
tick() {
|
||||
const prev = this.state;
|
||||
const t = prev.tick + 1;
|
||||
this.state = {
|
||||
tick: t,
|
||||
// graded count creeps up roughly every ~7s
|
||||
graded: prev.graded + (t % 7 === 0 ? 1 : 0),
|
||||
// neural % "breathes" 90–98 deterministically (sin-driven, test-safe)
|
||||
neural: 94 + Math.round(Math.sin(t / 3) * 4),
|
||||
aPlus: prev.aPlus + (t % 23 === 0 ? 1 : 0),
|
||||
cascades: prev.cascades,
|
||||
};
|
||||
this.state = { tick: this.state.tick + 1 };
|
||||
this._notify();
|
||||
return this.state;
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user