From d3637e7abde47a0067cae24887744b90d7be1b1f Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 11 Jul 2026 20:08:24 -0400 Subject: [PATCH] =?UTF-8?q?S6=20(a1):=20display=20=E2=80=94=20the=20full?= =?UTF-8?q?=20picture=20under=20the=20grammar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar. StatStrip violations fixed: MovementChip before the grade (market context before model output); ViabilityChips after the archetype (identity is one contiguous run). - Line-movement sparklines: intradayRefreshService.trackHistory captures real {t,line} points per grade (seeded with the lock, deduped when flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline renders at >=3 points (green toward / amber against / dim flat). - Last-10 dot strips: services/last10Dots (streaksService accessors) -> /api/snapshot/:sport attaches last10_dots from rosterlogs:{sport}; StatStrip.DotStrip renders vs the LOCKED line, newest first. - CLV distribution: getModelAggregate emits clv_distribution (7 signed buckets, outliers clamped) only past the centralized n>=20 gate; ledger MODEL header renders the bar strip. - Global search: SearchModal (cmd-K via GlobalHosts + window.__search), players via /api/players/search per sport + static lib/teams.js (soccer deliberately absent); Nav search icon + Search first in the mobile More sheet. Explore tab untouched. - Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on first frame, S33 visible-floor rule) + IBM Plex Mono preload:false (4 decorative font files off the slow-4G critical path). 2654 -> 2698 tests (226 suites) green; web build exit 0. Co-Authored-By: Claude Fable 5 --- BACKEND_HANDOFF.md | 18 ++ BUILD-STATE.md | 29 +++ CLAUDE.md | 33 +++ specs/ROW-GRAMMAR.md | 86 +++++++ src/routes/snapshot.js | 12 +- src/services/intradayRefreshService.js | 50 ++++- src/services/last10Dots.js | 109 +++++++++ src/services/ledgerService.js | 49 +++- src/services/streaksService.js | 6 + tests/unit/intradayRefresh.test.js | 63 +++++- tests/unit/last10Dots.test.js | 96 ++++++++ tests/unit/ledgerService.test.js | 61 +++++ tests/unit/rowGrammar.test.js | 121 ++++++++++ tests/unit/searchModal.test.js | 92 ++++++++ web/src/app/globals.css | 7 +- web/src/app/layout.tsx | 6 + web/src/app/ledger/page.tsx | 47 ++++ web/src/components/BottomTabBar.tsx | 32 +++ web/src/components/Nav.tsx | 14 ++ web/src/components/vyndr/GlobalHosts.tsx | 17 ++ web/src/components/vyndr/SearchModal.tsx | 272 +++++++++++++++++++++++ web/src/components/vyndr/Sparkline.tsx | 8 +- web/src/components/vyndr/StatStrip.tsx | 78 ++++++- web/src/components/vyndr/index.ts | 4 + web/src/lib/slateAdapter.js | 4 + web/src/lib/teams.js | 119 ++++++++++ 26 files changed, 1408 insertions(+), 25 deletions(-) create mode 100644 specs/ROW-GRAMMAR.md create mode 100644 src/services/last10Dots.js create mode 100644 tests/unit/last10Dots.test.js create mode 100644 tests/unit/rowGrammar.test.js create mode 100644 tests/unit/searchModal.test.js create mode 100644 web/src/components/vyndr/SearchModal.tsx create mode 100644 web/src/lib/teams.js diff --git a/BACKEND_HANDOFF.md b/BACKEND_HANDOFF.md index 69891d2..24bc271 100644 --- a/BACKEND_HANDOFF.md +++ b/BACKEND_HANDOFF.md @@ -207,6 +207,24 @@ The system's track record — settled grades vs real results. Written by 'push', actual:number }` once the game is final. `AccuracyBadge` + `StatStrip.OutcomeChip` render these. Frontend proxy: `web/src/app/api/accuracy`. +### S6 (A1 board) — display enrichments on the same payloads +- `GET /api/snapshot/:sport` grades may also carry: + - `last10_dots: boolean[]` — last ≤10 games vs the LOCKED line, newest + first (server-side from `rosterlogs:{sport}` via `services/last10Dots`). + Absent when there's no real log / no accessor for the stat. + - `history: [{ t: string, line: number }]` — REAL captured line points + from the intraday refresh (seeded with the locked line, deduped when + flat, capped 24). The StatStrip sparkline renders only at ≥3 points. + `slateAdapter.buildPlayerStripsFromProps` threads both onto `StripProp` + (`last10Dots` / `history`); `StatStrip.DotStrip` + `LineSparkline` render. +- `GET /api/ledger/model` `aggregate` may carry `clv_distribution: + [{ label, min, max, side:'faded'|'flat'|'beat', count }]` — seven ordered + buckets over settled signed CLV ([-2,-1) … (1,2], outliers clamped into + the edge buckets). **null below the n≥20 gate** (the gate lives in + `getModelAggregate`, nowhere else). Ledger MODEL header renders the strip. +- Global search: `window.__search()` opens the ⌘K SearchModal (players via + `/api/players/search` per sport; teams from the static `web/src/lib/teams.js`). + ## 8. Public ledger profiles (`src/routes/profiles.js`, A1 Session 10) Strava-for-betting v1. Table `public_profiles` (migration 022 — apply diff --git a/BUILD-STATE.md b/BUILD-STATE.md index dbb45b4..2c095f2 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -3,6 +3,35 @@ ## Last Updated 2026-07-11 +## Session S6 (a1 board, 2026-07-11) — Display: the full picture under the grammar ✅ +Spec/law: `specs/ROW-GRAMMAR.md` (locked by `tests/unit/rowGrammar.test.js`). +2654 → **2698 tests** (226 suites), web build exit 0. +- **ROW-GRAMMAR.md** — canonical prop-row slot order (identity → viability → + stat+line → market context → model output → outcome → actions → provenance), + color law (green=edge/best/positive, amber=caution/steam, red=settled-negative + only, dim=absent), mark law, mobile stacking, no-truncation law. StatStrip + violations FIXED: MovementChip moved before the grade (market before model); + ViabilityChips moved after the archetype (identity is one contiguous run). +- **Line-movement sparklines** — `intradayRefreshService.trackHistory` appends + real {t, line} points per grade (seeded with the lock, deduped when flat, + capped 24) inside the snapshot it already writes back. `StatStrip.LineSparkline` + renders at ≥3 points (green toward / amber against / dim flat — never red). +- **Last-10 dot strips** — `services/last10Dots` (streaksService accessors) → + `/api/snapshot/:sport` attaches `last10_dots` from `rosterlogs:{sport}`; + `StatStrip.DotStrip` renders ●/○ vs the LOCKED line, newest first. +- **CLV distribution** — `getModelAggregate` emits `clv_distribution` (7 signed + buckets, outliers clamped) ONLY past the n≥20 gate (null below — gate stays + centralized). Ledger MODEL header renders the green/red/dim bar strip. +- **Global search** — SearchModal (⌘K / Ctrl-K via GlobalHosts, `window.__search`), + players per sport via /api/players/search + static `lib/teams.js` (30 MLB / + 30 NBA / 13 WNBA; soccer deliberately absent — no canonical registry). Nav + search icon (mobile path) + Search first in the BottomTabBar More sheet. + Explore tab untouched. +- **Landing LCP** — `fade-up` floored at opacity .6 (hero h1 is contentful on + first frame; also fixes the S33 visible-floor rule) + IBM Plex Mono + `preload:false` (4 decorative font files off the slow-4G critical path). + PageSpeed re-measure is a post-deploy runbook step (cannot run here). + ## Session S9 (a1 board, 2026-07-11) — Slip Reader: zero-API OCR ✅ Spec: `specs/a1-s9-slip-reader.md`. +34 tests (24 unit + 10 integration). - **slipReader service** — tesseract.js (self-hosted WASM, Apache-2.0, zero diff --git a/CLAUDE.md b/CLAUDE.md index 47ddac5..b39ce1d 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -934,6 +934,39 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section). `npx jest --testPathIgnorePatterns "/node_modules/"` from a worktree (CLI replaces the config array). +## Row Grammar + Display Layer (Session S6, A1 board — non-obvious) +- **`specs/ROW-GRAMMAR.md` is LAW**, locked by `tests/unit/rowGrammar.test.js` + (source-order assertions on StatStrip). Prop-row slot order: identity → + viability → stat+line → market context (best dot, movement chip) → model + output (revision strike, grade) → outcome → actions → provenance. If you + reorder a StatStrip element, amend the spec + test in the same commit or + the suite fails. Red = settled-negative ONLY (miss/dead/stale/faded); the + line sparkline is green(toward)/amber(against)/dim(flat), never red. +- **Sparkline history is captured by `intradayRefreshService.trackHistory`** + — {t, line} on each grade, seeded with the LOCKED line, deduped when flat + (a point means the line MOVED), capped 24, persisted inside the snapshot + write-back (no new Redis keys). `StatStrip.LineSparkline` needs ≥3 points. + `Number(null) === 0` struck again here — all three S6 numeric paths + (trackHistory, last10Dots line, clvBucketIndex) use strict null guards. +- **`last10_dots` is attached at READ time** in `routes/snapshot.js` from + `rosterlogs:{sport}` via `services/last10Dots` (reuses streaksService + accessors — new MLB accessors runs/doubles/triples/outs live THERE, one + source of truth). Dots score vs the LOCKED line (gradedAt.line), not the + current line. +- **`clv_distribution` lives in `getModelAggregate`** behind the SAME n≥20 + gate as hit_pct (null below) — never re-derive the gate in a consumer. + Buckets clamp outliers into the edges ([-2,-1) … (1,2]). +- **Global search** — `window.__search` + ⌘K registered in GlobalHosts; + SearchModal fans out to `/api/players/search` per sport (MLB/NBA/WNBA) + + static `web/src/lib/teams.js` (soccer deliberately ABSENT — no canonical + registry; add it with the soccer team hub, not before). BottomTabBar keeps + the Explore tab; Search is the first More-sheet item. +- **LCP mechanics:** `fade-up` now floors at opacity .6 — a 0-start entrance + animation makes the hero h1 a LATE LCP candidate (invisible content isn't + contentful). IBM Plex Mono is `preload:false` (decorative face; its 4 + weight files competed with Inter + CSS on slow 4G). Measure before/after + via PageSpeed post-deploy — not runnable from the box. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/specs/ROW-GRAMMAR.md b/specs/ROW-GRAMMAR.md new file mode 100644 index 0000000..3b3ee89 --- /dev/null +++ b/specs/ROW-GRAMMAR.md @@ -0,0 +1,86 @@ +# VYNDR — ROW-GRAMMAR.md v1.0 +### The row grammar law. Density is unlimited when grammar is fixed: every element is DATA with ONE meaning in the SAME slot on every row of its type. Locked by `tests/unit/rowGrammar.test.js` — a slot-order change that isn't a deliberate spec amendment fails the suite. + +## 1. THE LAW +1. **One meaning per element.** A mark (dot, chip, strikethrough, color) means exactly one thing, everywhere it appears. +2. **One slot per element.** The same element renders in the same position on every row of its type. A reader who learns one row has learned every row. +3. **All data is mono** (`var(--mono)` / `.mono`) with `tabular-nums` where numbers align. Data never glitches. +4. **No truncation, ever, of a player name, a game time, or a pitcher line.** Rows wrap or stack; they do not ellipsize identity or schedule facts. (`text-overflow: ellipsis` is banned on those elements.) +5. **Absent beats wrong.** A slot with no real data renders nothing — never a placeholder value, never a fabricated zero. + +## 2. CANONICAL PROP-ROW SLOT ORDER +Every prop row (StatStrip snapshot mode, and any future surface that renders a +graded prop inline) reads left → right in this fixed order: + +``` +[1 identity] [2 viability] [3 stat+line+side] [4 market context] [5 model output] [6 outcome] [7 actions] [8 provenance] +``` + +| slot | contents | notes | +|---|---|---| +| 1 identity | player name · team · archetype badge | strip header — name appears ONCE per strip (S42 law) | +| 2 viability | lineup chip (CONFIRMED / NOT IN LINEUP / PROJ) · injury chip | real feeds only; absent feeds → no chips | +| 3 stat+line+side | `TB O1.5` | white, mono — the subject of the row | +| 4 market context | best-price dot · movement chip (STEAM ▲ / VALUE ▲) | what the MARKET is doing, before what the model says | +| 5 model output | revision strikethrough (original grade) → current grade badge | history then present: `A̶ B` reads "was A, now B" | +| 6 outcome | settled chip (✓ HIT / ✕ MISS / PUSH + actual) | once settled, actions (slot 7) disappear — the bet is over | +| 7 actions | parlay `+` · BOOK IT ⟶ | suppressed when dead or settled | +| 8 provenance | `Graded 2h ago at -115` | the receipt, always last | + +**Sub-line (stat + market context, one line under the row, in this order):** +`[last-10 dot strip] [line-movement sparkline] [current-line delta]` +- Each sub-line element renders only when its real data exists (dots need a + real game log; the sparkline needs ≥3 captured history points; the delta + needs a previous snapshot). Zero real data → no sub-line. + +**Strip header order (slot 1 + 2):** name → team → archetype → viability +chips. Identity is one contiguous run; status chips follow it. + +## 3. COLOR LAW — one meaning per color +| color | meaning | examples | +|---|---|---| +| **signal green** `--g-a` / `--hit` | edge / best / confirmed-positive | grade-A badge, best-price dot, VALUE chip, HIT chip, CONFIRMED lineup, delta TOWARD, CLV beat, dot-strip filled ● (cleared the line) | +| **amber** `--amber` | caution / steam / building | STEAM chip, GTD injury, delta AWAY, RECORD BUILDING, SYNC at 1.5× | +| **red** `--miss` | settled-negative truth ONLY: miss / dead / stale / faded close | MISS chip, NOT IN LINEUP, OUT, SYNC STALE, CLV faded | +| **dim** `--text-1/2` | absent / projected / neutral | PROJ lineup, awaiting copy, PUSH, dot-strip hollow ○ (didn't clear) | + +Red is never used for mere movement-against or a lower price — those are amber +(caution) and plain (fact). The line-movement sparkline is therefore green when +the net move is TOWARD the graded side, amber when net AGAINST, dim when flat — +never red (nothing has settled). + +## 4. MARK LAW — the micro-vocabulary +- **● / ○ dot strip** — last 10 games vs TONIGHT'S locked line, newest first. + Filled green = that game's stat cleared the line; hollow dim = it didn't. + Real game logs only; no log → no dots. +- **sparkline** — the REAL captured line over time (intraday refresh history, + {t, line} points, capped 24). Never rendered under 3 real points. +- **strikethrough** — a dead or superseded model read (NOT IN LINEUP grade, + revised-from grade). The original is preserved, struck, never deleted. +- **▲ ▼** — line movement direction (market fact). **✓ ✕** — settled result. +- **glow** — A-tier only (NORTH STAR rule). Chrome may glitch; data never. + +## 5. MOBILE STACKING (≤390px physics) +- A prop row WRAPS (`flexWrap`) — slots keep their order, overflowing slots + continue on the next visual line. Order is never re-shuffled for width. +- The sub-line always stacks BELOW its row, full width. +- Strip header wraps after the identity run; viability chips may drop to a + second line but stay ahead of the stats run. +- Wide data grids (`game-lines-grid`) scroll horizontally inside their own + container; the page never scrolls horizontally. +- Names, game times, and pitcher lines wrap to a new line rather than + truncate (law §1.4). + +## 6. AUDIT (Session 6, A1 board) — violations found and fixed +1. **MovementChip rendered AFTER the grade** (market context inside the model + slot). Moved to slot 4, before the revision/grade pair. +2. **ViabilityChips rendered between team and archetype**, splitting the + identity run. Moved after the archetype badge (identity → viability). +3. `fade-up` keyframe started at `opacity: 0` — violating the S33 "entrance + keyframes floor at the visible state" rule (a paused frame was invisible, + and the hero LCP paint was delayed by the animation). Floored at `.6`. +Everything else already conformed: best-price dot precedes the grade, the +outcome chip suppresses actions, provenance is last, awaiting rows carry the +honest schedule copy. + +— ROW-GRAMMAR v1.0 · July 2026 — diff --git a/src/routes/snapshot.js b/src/routes/snapshot.js index f70daf6..897d5d2 100644 --- a/src/routes/snapshot.js +++ b/src/routes/snapshot.js @@ -13,6 +13,9 @@ const express = require('express'); const { createRateLimit } = require('../middleware/rateLimit'); const { cacheGet } = require('../utils/redis'); const { nameKey } = require('../utils/playerName'); +// S6 (A1 board) — ●●○●● last-10 vs tonight's locked line, computed from the +// rosterlogs blob the snapshot pipeline already writes. Pure, cache-only. +const { indexRosterLogs, attachLast10Dots } = require('../services/last10Dots'); const router = express.Router(); router.use(createRateLimit({ windowMs: 60_000, max: 60 })); @@ -79,20 +82,23 @@ router.get('/summary', async (req, res) => { router.get('/:sport', async (req, res) => { const sport = String(req.params.sport || '').toLowerCase(); try { - const [snap, outcomeLog] = await Promise.all([ + const [snap, outcomeLog, rosterBlob] = await Promise.all([ cacheGet(`snapshot:${sport}:latest`), cacheGet(`outcomes:${sport}:log`), + cacheGet(`rosterlogs:${sport}`), ]); const idx = outcomeIndex(outcomeLog); + const roster = indexRosterLogs(rosterBlob); + const enrich = (grades) => attachLast10Dots(attachOutcomes(grades, idx), roster, sport); if (snap && Array.isArray(snap.grades)) { res.set('Cache-Control', 'public, max-age=30'); - return res.json({ sport, updated_at: snap.updated_at, grades: attachOutcomes(snap.grades, idx), deltas: snap.deltas || [] }); + return res.json({ sport, updated_at: snap.updated_at, grades: enrich(snap.grades), deltas: snap.deltas || [] }); } // Fallback: the grades envelope (no deltas yet). const env = await cacheGet(`grades:${sport}`); const grades = env && Array.isArray(env.grades) ? env.grades : []; res.set('Cache-Control', 'public, max-age=30'); - return res.json({ sport, updated_at: env && env.updated_at, grades: attachOutcomes(grades, idx), deltas: [] }); + return res.json({ sport, updated_at: env && env.updated_at, grades: enrich(grades), deltas: [] }); } catch (err) { console.error('[snapshot]', err.message); return res.status(200).json({ sport, grades: [], deltas: [] }); diff --git a/src/services/intradayRefreshService.js b/src/services/intradayRefreshService.js index 3fb29c9..8561562 100644 --- a/src/services/intradayRefreshService.js +++ b/src/services/intradayRefreshService.js @@ -36,6 +36,7 @@ const STEAM_NOISE = 0.5; // ignore movement below this (both directions) const REGRADE_TRIGGER = 1.0; // moved-against threshold that triggers a re-grade const SNAP_TTL = 24 * 3600; // keep in sync with snapshotService const TICKER_MOVE_CAP = 6; +const HISTORY_CAP = 24; // {t, line} points per grade (S6 sparklines) const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10 }; const rank = (g) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99); @@ -52,6 +53,36 @@ function indexOddsProps(props) { return map; } +/** + * S6 (A1 board) — line-history capture for the row sparklines. Appends the + * CURRENT real feed line as a {t, line} point on the grade's movement + * tracking, persisted in the snapshot this refresh already writes back + * (zero new keys). Rules: + * - real points only: an unparseable current line appends nothing; + * - seed: an empty history first records the LOCKED line at its own + * graded timestamp (a real captured value); + * - dedupe: consecutive identical lines don't append — a point means the + * line MOVED, so ≥3 points = a real movement story, not a flat pulse; + * - cap: last HISTORY_CAP (24) points. + */ +function trackHistory(g, currentLine, ts) { + const prev = Array.isArray(g.history) ? g.history : []; + // Strict: Number(null) is 0 — a fabricated line (Data Semantics Rule). + const current = currentLine == null ? NaN : Number(currentLine); + if (!Number.isFinite(current)) return prev.length > 0 ? prev : undefined; + let hist = prev; + if (hist.length === 0) { + const locked = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line; + const lockedNum = Number(locked); + if (locked != null && Number.isFinite(lockedNum)) { + hist = [{ t: (g.gradedAt && g.gradedAt.timestamp) || ts, line: lockedNum }]; + } + } + const last = hist[hist.length - 1]; + if (!last || last.line !== current) hist = [...hist, { t: ts, line: current }]; + return hist.slice(-HISTORY_CAP); +} + /** Signed movement RELATIVE TO THE GRADED SIDE: positive = toward (with). * Strict null-safe parse — Number(null) is 0, a fabricated line. */ function signedDelta(side, lockedLine, currentLine) { @@ -110,9 +141,14 @@ async function runIntradayRefresh(sport, opts = {}) { if (!prop || prop.line == null || locked == null) { grades.push(g); continue; } checked += 1; + // S6 — capture the real current line into the grade's {t, line} history + // (sparkline fuel). Rides inside the snapshot write below — no new keys. + const history = trackHistory(g, prop.line, ts); + const withHist = (obj) => (history ? { ...obj, history } : obj); + const delta = signedDelta(g.direction, locked, prop.line); if (delta == null || Math.abs(delta) < STEAM_NOISE) { - grades.push({ ...g, movement: null }); + grades.push(withHist({ ...g, movement: null })); continue; } @@ -120,7 +156,7 @@ async function runIntradayRefresh(sport, opts = {}) { if (delta > 0) { // Moved WITH the grade — the market is chasing our number. steam += 1; - grades.push({ ...g, movement: { kind: 'steam', delta, currentLine: current, at: ts } }); + grades.push(withHist({ ...g, movement: { kind: 'steam', delta, currentLine: current, at: ts } })); if (Math.abs(delta) >= REGRADE_TRIGGER) { moveEvents.push(moveEvent(sp, g, locked, current, ts)); } @@ -129,7 +165,7 @@ async function runIntradayRefresh(sport, opts = {}) { // Moved AGAINST the grade. if (Math.abs(delta) < REGRADE_TRIGGER) { - grades.push({ ...g, movement: { kind: 'against', delta, currentLine: current, at: ts } }); + grades.push(withHist({ ...g, movement: { kind: 'against', delta, currentLine: current, at: ts } })); continue; } @@ -146,19 +182,19 @@ async function runIntradayRefresh(sport, opts = {}) { if (!res || !res.grade || res.insufficient_data || rank(res.grade) <= rank(g.grade)) { // Grade holds (or the model refuses to re-read) → better entry, same read. value += 1; - grades.push({ ...g, movement: { kind: 'value', delta, currentLine: current, at: ts } }); + grades.push(withHist({ ...g, movement: { kind: 'value', delta, currentLine: current, at: ts } })); continue; } // Grade DROPS → public revision. Original grade preserved once, forever. revised += 1; const fromGrade = g.revised_from_grade || g.grade; - grades.push({ + grades.push(withHist({ ...g, grade: res.grade, revised_from_grade: fromGrade, movement: { kind: 'revised', delta, currentLine: current, at: ts }, - }); + })); try { await deps.ledger.applyRevision(sp, { playerKey: nameKey(player), stat, line: Number(locked), @@ -213,5 +249,5 @@ module.exports = { runIntradayRefresh, runAllIntradayRefreshes, inSlateHours, - __internals: { signedDelta, indexOddsProps, moveEvent, rank, STEAM_NOISE, REGRADE_TRIGGER }, + __internals: { signedDelta, indexOddsProps, moveEvent, rank, trackHistory, STEAM_NOISE, REGRADE_TRIGGER, HISTORY_CAP }, }; diff --git a/src/services/last10Dots.js b/src/services/last10Dots.js new file mode 100644 index 0000000..48dcaef --- /dev/null +++ b/src/services/last10Dots.js @@ -0,0 +1,109 @@ +'use strict'; + +/** + * last10Dots — S6 (A1 board, ROW-GRAMMAR §4). + * + * ●●○●● — a grade's last 10 REAL games scored against TONIGHT'S locked line: + * per game, hit = stat value ≥ line → boolean, newest first. Pure math over + * the `rosterlogs:{sport}` blob the snapshot pipeline already writes (games + * are stored most-recent-first) — zero API calls, zero new keys. + * + * Stat-field access reuses the SAME accessors as streaksService (one source + * of truth for the several field spellings each feed uses). A stat type with + * no accessor, an empty log, or a non-finite line → null: absent beats wrong, + * the UI renders nothing. + */ + +const { __internals: streakAccessors } = require('./streaksService'); +const { nameKey } = require('../utils/playerName'); + +const { nba, mlb, soccer } = streakAccessors; + +// grade stat_type → streaksService accessor, per sport. Keep keyed on the +// grade-gate stat names (routes/analyze.js vocabulary), not market keys. +const STAT_ACCESSORS = { + mlb: { + hits: mlb.hits, + total_bases: mlb.totalBases, + home_runs: mlb.homeRuns, + rbi: mlb.rbi, + runs: mlb.runs, + stolen_bases: mlb.stolenBases, + walks: mlb.walks, + strikeouts: mlb.strikeouts, + earned_runs: mlb.earnedRuns, + hits_allowed: mlb.hits, // pitching log's hits = hits allowed + innings_pitched: mlb.inningsPitched, + doubles: mlb.doubles, + triples: mlb.triples, + outs: mlb.outs, + }, + nba: { + points: nba.points, + rebounds: nba.rebounds, + assists: nba.assists, + threes: nba.threes, + steals: nba.steals, + blocks: nba.blocks, + pra: nba.pra, + }, + soccer: { + goals: soccer.goals, + assists: soccer.assists, + shots_on_target: soccer.shotsOnTarget, + }, +}; +STAT_ACCESSORS.wnba = STAT_ACCESSORS.nba; + +const MAX_DOTS = 10; + +/** + * Compute the dot booleans for one player's game log vs a line. + * `games` most-recent-first (the rosterlogs order). Returns boolean[] + * (newest first, ≤10) or null when it can't be computed honestly. + */ +function computeLast10Dots(games, sport, statType, line) { + const sp = String(sport || '').toLowerCase(); + const accessor = (STAT_ACCESSORS[sp] || {})[String(statType || '').toLowerCase()]; + // Strict: Number(null) is 0 — a fabricated line (Data Semantics Rule). + const ln = line == null ? NaN : Number(line); + if (!accessor || !Number.isFinite(ln)) return null; + const rows = Array.isArray(games) ? games.filter(Boolean).slice(0, MAX_DOTS) : []; + if (rows.length === 0) return null; + return rows.map((g) => accessor(g) >= ln); +} + +/** Index a rosterlogs blob by nameKey → games (most-recent-first). */ +function indexRosterLogs(blob) { + const map = {}; + for (const e of Array.isArray(blob) ? blob : []) { + if (e && e.name && Array.isArray(e.games) && e.games.length > 0) { + map[nameKey(e.name)] = e.games; + } + } + return map; +} + +/** + * Attach `last10_dots` to each grade that has a real log + locked line. + * Non-destructive: grades without a computable strip pass through untouched. + */ +function attachLast10Dots(grades, rosterIndex, sport) { + if (!rosterIndex || Object.keys(rosterIndex).length === 0) return grades; + return (grades || []).map((g) => { + const player = g.player || g.player_name; + if (!player) return g; + const games = rosterIndex[nameKey(player)]; + if (!games) return g; + const line = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line; + const dots = computeLast10Dots(games, sport, g.stat_type || g.stat, line); + return dots ? { ...g, last10_dots: dots } : g; + }); +} + +module.exports = { + computeLast10Dots, + indexRosterLogs, + attachLast10Dots, + __internals: { STAT_ACCESSORS, MAX_DOTS }, +}; diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index b32d05e..f12f8fa 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -38,6 +38,37 @@ const AGG_FETCH_LIMIT = 5000; /** Below this many settled rows, callers must not render a percentage. */ const MIN_AGG_SAMPLE = 20; +/** + * S6 (A1 board) — CLV distribution buckets (the MODEL tab strip). Signed CLV: + * positive = beat the close. Outliers clamp into the edge buckets so every + * settled clv lands somewhere. `side` drives the UI color (green = beat, + * red = faded, dim = flat) — same meanings as clv_result. + */ +const CLV_BUCKETS = [ + { label: '[-2,-1)', min: -2, max: -1, side: 'faded' }, + { label: '[-1,-.5)', min: -1, max: -0.5, side: 'faded' }, + { label: '[-.5,0)', min: -0.5, max: 0, side: 'faded' }, + { label: '0', min: 0, max: 0, side: 'flat' }, + { label: '(0,.5]', min: 0, max: 0.5, side: 'beat' }, + { label: '(.5,1]', min: 0.5, max: 1, side: 'beat' }, + { label: '(1,2]', min: 1, max: 2, side: 'beat' }, +]; + +/** Bucket index for one signed clv value (clamped into the edge buckets). */ +function clvBucketIndex(clv) { + const v = numOrNull(clv); // strict — Number(null) is 0, a fabricated CLV + if (v == null) return -1; + if (v === 0) return 3; + if (v < 0) { + if (v >= -0.5) return 2; + if (v >= -1) return 1; + return 0; // ≤ -1 clamps into [-2,-1) + } + if (v <= 0.5) return 4; + if (v <= 1) return 5; + return 6; // > 1 clamps into (1,2] +} + function isConfigured() { return Boolean(process.env.SUPABASE_URL && (process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY)); @@ -397,6 +428,7 @@ async function getModelAggregate(opts = {}) { 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, + clv_distribution: null, // S6 — set past the n≥20 gate only pending: 0, }; if (!opts.sb && !isConfigured()) return empty; @@ -405,7 +437,7 @@ async function getModelAggregate(opts = {}) { 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, grade'); + .select('outcome, clv_result, clv, player_key, grade'); settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null); settledQ = settledQ .not('outcome', 'is', null) @@ -471,6 +503,19 @@ async function getModelAggregate(opts = {}) { if (agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) { agg.beat_close_pct = Math.round((agg.clv_beat / agg.clv_sample) * 100); } + // S6 (A1 board) — clv_distribution rides the SAME n≥20 gate (this is the + // single home of the gate — consumers never re-derive it). Null below the + // sample floor or with zero settled clv values; the UI renders nothing. + agg.clv_distribution = null; + if (agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) { + const dist = CLV_BUCKETS.map((b) => ({ ...b, count: 0 })); + let counted = 0; + for (const r of settledRows || []) { + const i = clvBucketIndex(r.clv); + if (i >= 0) { dist[i].count += 1; counted += 1; } + } + if (counted > 0) agg.clv_distribution = dist; + } return agg; } @@ -486,6 +531,6 @@ module.exports = { __internals: { rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor, dateET, sideOf, oddsForSide, isConfigured, CONFLICT, - teamOpponentFor, teamsMatch, + teamOpponentFor, teamsMatch, clvBucketIndex, CLV_BUCKETS, }, }; diff --git a/src/services/streaksService.js b/src/services/streaksService.js index d8e3d8d..3c8e8b3 100644 --- a/src/services/streaksService.js +++ b/src/services/streaksService.js @@ -61,6 +61,12 @@ const mlb = { strikeouts: (r) => num(r, 'strikeOuts', 'strikeouts', 'pitcherK', 'K', 'so'), inningsPitched: (r) => num(r, 'inningsPitched', 'ip', 'IP'), earnedRuns: (r) => num(r, 'earnedRuns', 'er', 'ER'), + // S6 (A1 board) — last10Dots reads these too (same source of truth for + // every stat-field spelling; don't duplicate accessors elsewhere). + runs: (r) => num(r, 'runs', 'R'), + doubles: (r) => num(r, 'doubles', '2B'), + triples: (r) => num(r, 'triples', '3B'), + outs: (r) => num(r, 'outs'), }; mlb.onBase = (r) => mlb.hits(r) + mlb.walks(r) + mlb.hbp(r); mlb.isQualityStart = (r) => mlb.inningsPitched(r) >= 6 && mlb.earnedRuns(r) <= 3; diff --git a/tests/unit/intradayRefresh.test.js b/tests/unit/intradayRefresh.test.js index 7be8e28..f7d0261 100644 --- a/tests/unit/intradayRefresh.test.js +++ b/tests/unit/intradayRefresh.test.js @@ -4,7 +4,7 @@ // real feed values; the refresh recaptures the close. const { runIntradayRefresh, inSlateHours, __internals } = require('../../src/services/intradayRefreshService'); -const { signedDelta } = __internals; +const { signedDelta, trackHistory, HISTORY_CAP } = __internals; const NOW = '2026-07-11T20:00:00.000Z'; @@ -125,6 +125,67 @@ describe('runIntradayRefresh — directional handling', () => { }); }); +// S6 (A1 board) — sparkline fuel: {t, line} history capture on each grade's +// movement tracking, persisted inside the snapshot write-back (no new keys). +describe('trackHistory — real {t, line} points, seeded, deduped, capped', () => { + const LOCK_TS = '2026-07-11T14:00:00.000Z'; + const g = (extra = {}) => ({ ...GRADE, gradedAt: { line: 0.5, odds: -115, timestamp: LOCK_TS }, ...extra }); + + test('first capture seeds the LOCKED line at its own timestamp, then the move', () => { + const h = trackHistory(g(), 1.5, NOW); + expect(h).toEqual([{ t: LOCK_TS, line: 0.5 }, { t: NOW, line: 1.5 }]); + }); + + test('a flat line never appends a duplicate point (a point means it MOVED)', () => { + const h = trackHistory(g(), 0.5, NOW); // current == locked + expect(h).toEqual([{ t: LOCK_TS, line: 0.5 }]); + const again = trackHistory(g({ history: h }), 0.5, '2026-07-11T21:00:00.000Z'); + expect(again).toHaveLength(1); + }); + + test('an unparseable current line appends nothing (real points only)', () => { + expect(trackHistory(g(), null, NOW)).toBeUndefined(); + const prior = [{ t: LOCK_TS, line: 0.5 }]; + expect(trackHistory(g({ history: prior }), 'n/a', NOW)).toBe(prior); + }); + + test('capped at 24 points, newest kept', () => { + const prior = Array.from({ length: HISTORY_CAP }, (_, i) => ({ t: `t${i}`, line: i })); + const h = trackHistory(g({ history: prior }), 99, NOW); + expect(h).toHaveLength(HISTORY_CAP); + expect(h[HISTORY_CAP - 1]).toEqual({ t: NOW, line: 99 }); + expect(h[0]).toEqual({ t: 't1', line: 1 }); // oldest point aged out + }); +}); + +describe('runIntradayRefresh — history rides the snapshot write-back', () => { + test('a steam move persists history on the grade (locked point + current)', async () => { + const { deps, store } = harness({ grades: [GRADE], oddsProps: [prop(1.5)], analyzeResult: null }); + await runIntradayRefresh('mlb', deps); + const snap = store.get('snapshot:mlb:latest'); + expect(snap.grades[0].history).toEqual([ + { t: NOW, line: 0.5 }, // seeded from the lock (gradedAt.timestamp === NOW here) + { t: NOW, line: 1.5 }, + ]); + // grades:{sport} carries the same enriched grades. + expect(store.get('grades:mlb').grades[0].history).toHaveLength(2); + }); + + test('a flat run keeps a single seeded point — the sparkline stays hidden (<3)', async () => { + const { deps, store } = harness({ grades: [GRADE], oddsProps: [prop(0.5)], analyzeResult: null }); + await runIntradayRefresh('mlb', deps); + expect(store.get('snapshot:mlb:latest').grades[0].history).toEqual([{ t: NOW, line: 0.5 }]); + }); + + test('a prop gone from the feed leaves prior history untouched', async () => { + const withHist = { ...GRADE, stat_type: 'hits', history: [{ t: 'x', line: 1 }] }; + const { deps, store } = harness({ grades: [withHist], oddsProps: [prop(1.5)], analyzeResult: null }); + await runIntradayRefresh('mlb', deps); + const frozen = store.get('snapshot:mlb:latest').grades.find((g) => g.stat_type === 'hits'); + expect(frozen.history).toEqual([{ t: 'x', line: 1 }]); + }); +}); + describe('inSlateHours — noon–midnight ET', () => { test('3 PM ET yes, 4 AM ET no', () => { expect(inSlateHours(new Date('2026-07-11T19:00:00Z'))).toBe(true); // 3 PM EDT diff --git a/tests/unit/last10Dots.test.js b/tests/unit/last10Dots.test.js new file mode 100644 index 0000000..96cf98d --- /dev/null +++ b/tests/unit/last10Dots.test.js @@ -0,0 +1,96 @@ +// S6 (A1 board) — ●●○●● last-10 vs tonight's locked line (ROW-GRAMMAR §4). +// Pure math over the rosterlogs blob; stat access reuses streaksService's +// accessors (one source of truth for every field spelling). Real logs only. + +const fs = require('fs'); +const path = require('path'); +const { computeLast10Dots, indexRosterLogs, attachLast10Dots, __internals } = require('../../src/services/last10Dots'); + +describe('computeLast10Dots', () => { + const games = [ + { date: '2026-07-10', hits: 2, totalBases: 5 }, + { date: '2026-07-09', hits: 0, totalBases: 0 }, + { date: '2026-07-08', hits: 1, totalBases: 2 }, + ]; + + test('hit = value ≥ line, boolean per game, newest first', () => { + expect(computeLast10Dots(games, 'mlb', 'hits', 1.5)).toEqual([true, false, false]); + expect(computeLast10Dots(games, 'mlb', 'total_bases', 1.5)).toEqual([true, false, true]); + }); + + test('tolerates the alternate field spellings (streaksService accessors)', () => { + const alt = [{ H: 3 }, { h: 0 }]; + expect(computeLast10Dots(alt, 'mlb', 'hits', 0.5)).toEqual([true, false]); + }); + + test('nba/wnba stats work too (points, pra)', () => { + const log = [{ pts: 31, reb: 8, ast: 7 }, { pts: 12, reb: 2, ast: 1 }]; + expect(computeLast10Dots(log, 'wnba', 'points', 20.5)).toEqual([true, false]); + expect(computeLast10Dots(log, 'nba', 'pra', 40.5)).toEqual([true, false]); + }); + + test('caps at 10 games', () => { + const long = Array.from({ length: 14 }, (_, i) => ({ hits: i % 2 })); + expect(computeLast10Dots(long, 'mlb', 'hits', 0.5)).toHaveLength(10); + }); + + test('absent → nothing: unknown stat, no games, bad line all return null', () => { + expect(computeLast10Dots(games, 'mlb', 'exit_velocity', 1.5)).toBeNull(); + expect(computeLast10Dots([], 'mlb', 'hits', 1.5)).toBeNull(); + expect(computeLast10Dots(games, 'mlb', 'hits', null)).toBeNull(); + expect(computeLast10Dots(games, 'unknown_sport', 'hits', 1.5)).toBeNull(); + }); +}); + +describe('indexRosterLogs + attachLast10Dots', () => { + const blob = [ + { name: 'A.J. Ewing', team: 'NYY', games: [{ hits: 2 }, { hits: 0 }, { hits: 1 }] }, + { name: 'No Games Guy', team: 'BOS', games: [] }, + ]; + + test('indexes by nameKey (variants merge) and skips empty logs', () => { + const idx = indexRosterLogs(blob); + expect(idx['aj ewing']).toHaveLength(3); + expect(Object.keys(idx)).toHaveLength(1); + }); + + test('attaches last10_dots against the LOCKED line; untouched when absent', () => { + const idx = indexRosterLogs(blob); + const grades = [ + { player: 'AJ Ewing', stat_type: 'hits', line: 2.5, gradedAt: { line: 0.5 }, grade: 'A' }, + { player: 'Someone Else', stat_type: 'hits', line: 0.5, grade: 'B' }, + { player: 'AJ Ewing', stat_type: 'exit_velocity', line: 90, grade: 'C' }, + ]; + const out = attachLast10Dots(grades, idx, 'mlb'); + // locked line (0.5) is used, not the current line (2.5). + expect(out[0].last10_dots).toEqual([true, false, true]); + expect(out[1].last10_dots).toBeUndefined(); // no log → nothing + expect(out[2].last10_dots).toBeUndefined(); // no accessor → nothing + expect(out[1]).toBe(grades[1]); // pass-through untouched + }); + + test('empty roster index is a no-op', () => { + const grades = [{ player: 'AJ Ewing', stat_type: 'hits', line: 0.5 }]; + expect(attachLast10Dots(grades, {}, 'mlb')).toBe(grades); + }); +}); + +describe('wiring locks', () => { + test('the public snapshot route reads rosterlogs and attaches dots', () => { + const route = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'routes', 'snapshot.js'), 'utf8'); + expect(route).toContain('rosterlogs:${sport}'); + expect(route).toContain('attachLast10Dots'); + }); + test('the slate adapter threads last10_dots + history onto strip props', () => { + const adapter = fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', 'lib', 'slateAdapter.js'), 'utf8'); + expect(adapter).toContain('rec.last10_dots'); + expect(adapter).toContain('rec.history'); + }); + test('every accessor is a function (the streaksService contract holds)', () => { + for (const [sport, map] of Object.entries(__internals.STAT_ACCESSORS)) { + for (const [stat, fn] of Object.entries(map)) { + expect({ sport, stat, isFn: typeof fn === 'function' }).toEqual({ sport, stat, isFn: true }); + } + } + }); +}); diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index 1e84d0b..082e2db 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -271,3 +271,64 @@ describe('indexProps — prefers a book row with both sides priced', () => { expect(idx['a guy|hits'].book).toBe('fanduel'); }); }); + +// S6 (A1 board) — CLV distribution on the model aggregate. The n>=20 gate is +// centralized HERE (getModelAggregate) — consumers never re-derive it. +describe('getModelAggregate — clv_distribution (n>=20 gate lives in the service)', () => { + const { clvBucketIndex, CLV_BUCKETS } = ledger.__internals; + + test('below 20 settles → clv_distribution is null (never a small-sample chart)', async () => { + const sb = fakeSb(); + sb._state.selectResults = [ + Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })), + ]; + const agg = await ledger.getModelAggregate({ sb }); + expect(agg.clv_distribution).toBeNull(); + }); + + test('at 20+ settles → seven ordered buckets, counts bucketed by signed clv', async () => { + const sb = fakeSb(); + const rows = [ + ...Array.from({ length: 8 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })), + ...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 1.5 })), + ...Array.from({ length: 5 }, () => ({ outcome: 'miss', clv_result: 'faded', clv: -0.5 })), + ...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: 'flat', clv: 0 })), + { outcome: 'push', clv_result: 'faded', clv: -3 }, // outlier clamps into the edge bucket + ]; + sb._state.selectResults = [rows]; + const agg = await ledger.getModelAggregate({ sb }); + const dist = agg.clv_distribution; + expect(dist).toHaveLength(7); + expect(dist.map((b) => b.label)).toEqual(CLV_BUCKETS.map((b) => b.label)); + expect(dist[0]).toMatchObject({ side: 'faded', count: 1 }); // [-2,-1) — clamped -3 + expect(dist[2]).toMatchObject({ side: 'faded', count: 5 }); // [-.5,0) — -0.5 closed on the left + expect(dist[1]).toMatchObject({ side: 'faded', count: 0 }); // [-1,-.5) — empty + expect(dist[3]).toMatchObject({ side: 'flat', count: 2 }); // exactly 0 + expect(dist[4]).toMatchObject({ side: 'beat', count: 8 }); // (0,.5] + expect(dist[6]).toMatchObject({ side: 'beat', count: 4 }); // (1,2] — 1.5 + }); + + test('rows without a settled clv value never fabricate a bucket', async () => { + const sb = fakeSb(); + sb._state.selectResults = [ + Array.from({ length: 20 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: null })), + ]; + const agg = await ledger.getModelAggregate({ sb }); + expect(agg.clv_distribution).toBeNull(); // no real clv values → nothing + }); + + test('clvBucketIndex edges: boundaries land per the spec intervals', () => { + expect(clvBucketIndex(0)).toBe(3); + expect(clvBucketIndex(0.5)).toBe(4); // (0,.5] closed on the right + expect(clvBucketIndex(0.51)).toBe(5); + expect(clvBucketIndex(1)).toBe(5); // (.5,1] + expect(clvBucketIndex(1.01)).toBe(6); + expect(clvBucketIndex(-0.5)).toBe(2); // [-.5,0) closed on the left + expect(clvBucketIndex(-0.51)).toBe(1); // [-1,-.5) + expect(clvBucketIndex(-1)).toBe(1); // [-1,-.5) closed on the left + expect(clvBucketIndex(-1.01)).toBe(0); // [-2,-1) + expect(clvBucketIndex(-9)).toBe(0); // clamp + expect(clvBucketIndex(9)).toBe(6); // clamp + expect(clvBucketIndex(null)).toBe(-1); // absent beats wrong + }); +}); diff --git a/tests/unit/rowGrammar.test.js b/tests/unit/rowGrammar.test.js new file mode 100644 index 0000000..8b30e55 --- /dev/null +++ b/tests/unit/rowGrammar.test.js @@ -0,0 +1,121 @@ +// S6 (A1 board) — ROW-GRAMMAR source locks (specs/ROW-GRAMMAR.md). +// Density is unlimited when grammar is fixed: every element is DATA with ONE +// meaning in the SAME slot on every row of its type. These tests fail if a +// slot moves, a color changes meaning, or truncation sneaks onto a name. + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +const strip = read('components/vyndr/StatStrip.tsx'); +const spec = fs.readFileSync(path.join(__dirname, '..', '..', 'specs', 'ROW-GRAMMAR.md'), 'utf8'); + +/** Assert `markers` appear in this exact order in `src`, starting at `from`. */ +function assertOrder(src, markers, from = 0) { + let cursor = from; + for (const m of markers) { + const i = src.indexOf(m, cursor); + expect({ marker: m, found: i >= 0 }).toEqual({ marker: m, found: true }); + cursor = i + m.length; + } + return cursor; +} + +describe('ROW-GRAMMAR §2 — canonical prop-row slot order (snapshot mode)', () => { + test('stat+line → market context (dot, movement) → model output (revision, grade) → outcome → actions → provenance', () => { + // Anchor on the graded row's stat+side+line span (unique to it). + const base = strip.indexOf("{p.stat} {p.side}{p.line}"); + expect(base).toBeGreaterThan(-1); + assertOrder(strip, [ + '', // slot 4a — best available price + '', // slot 4b — market movement + 'p.revisedFrom', // slot 5a — revision strikethrough + '', // slot 6 — settled result + '', // slot 7a — action + '', // slot 7b — action + 'Graded {p.gradedAt.ago}', // slot 8 — provenance, always last + ], base); + }); + + test('sub-line order: last-10 dots → sparkline → current-line delta', () => { + const base = strip.indexOf('ROW-GRAMMAR sub-line'); + expect(base).toBeGreaterThan(-1); + assertOrder(strip, [ + '', + '', + 'Current {p.delta.currentLine}', + ], base); + }); + + test('strip header: identity run (name → team → archetype) then viability chips', () => { + const base = strip.indexOf('// compact'); + expect(base).toBeGreaterThan(-1); + assertOrder(strip, [ + '', + '', + ], base); + }); + + test('settled/dead rows suppress actions (the bet is over)', () => { + expect(strip).toContain('{!p.outcome && !p.dead && }'); + expect(strip).toContain('{!p.outcome && !p.dead && }'); + }); +}); + +describe('ROW-GRAMMAR §3 — one meaning per color', () => { + const section = (start, end) => { + const a = strip.indexOf(start); + const b = end ? strip.indexOf(end, a) : strip.length; + expect(a).toBeGreaterThan(-1); + return strip.slice(a, b === -1 ? strip.length : b); + }; + + test('sparkline is green/amber/dim — never red (nothing has settled)', () => { + const src = section('export function LineSparkline', 'export function ViabilityChips'); + expect(src).toContain("'var(--g-a)'"); + expect(src).toContain("'var(--amber)'"); + expect(src).not.toContain('--miss'); + }); + + test('dot strip: filled = signal green, hollow = dim (never red/amber)', () => { + const src = section('export function DotStrip', 'export function LineSparkline'); + expect(src).toContain('var(--g-a'); + expect(src).toContain('var(--text-2'); + expect(src).not.toContain('--miss'); + expect(src).not.toContain('--amber'); + }); + + test('movement chips keep STEAM amber / VALUE green (QA.20 family)', () => { + const src = section('function MovementChip', 'export interface StripArchetype'); + expect(src).toContain("steam ? 'var(--amber"); + expect(src).not.toContain('--miss'); + }); +}); + +describe('ROW-GRAMMAR §1.4 — no truncation of names/times/pitchers', () => { + test('StatStrip never ellipsizes', () => { + expect(strip).not.toMatch(/textOverflow|text-overflow|ellipsis/); + }); + test('SearchModal result names wrap, never truncate', () => { + const sm = read('components/vyndr/SearchModal.tsx'); + expect(sm).not.toMatch(/textOverflow|ellipsis/); + }); +}); + +describe('ROW-GRAMMAR — the law is written down and wired', () => { + test('spec exists with the canonical slot order and color law', () => { + expect(spec).toContain('CANONICAL PROP-ROW SLOT ORDER'); + expect(spec).toContain('one meaning per color'); + expect(spec).toContain('never'); + expect(spec).not.toMatch(/!/); // VOICE — no exclamation points + }); + test('data marks are mono', () => { + // DotStrip + sub-line render with the mono class (data never sans). + const dot = strip.slice(strip.indexOf('export function DotStrip'), strip.indexOf('export function LineSparkline')); + expect(dot).toContain('className="mono"'); + }); +}); diff --git a/tests/unit/searchModal.test.js b/tests/unit/searchModal.test.js new file mode 100644 index 0000000..15e98d1 --- /dev/null +++ b/tests/unit/searchModal.test.js @@ -0,0 +1,92 @@ +// S6 (A1 board) — global search: ⌘K modal (players + teams) + mobile entry +// points. Source locks + the static team registry's honesty rules. + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const { TEAMS, searchTeams, teamHref } = require('../../web/src/lib/teams'); + +describe('lib/teams — the static registry', () => { + test('full leagues: 30 MLB, 30 NBA, 13 WNBA — soccer deliberately absent', () => { + const by = (sp) => TEAMS.filter((t) => t.sport === sp); + expect(by('mlb')).toHaveLength(30); + expect(by('nba')).toHaveLength(30); + expect(by('wnba')).toHaveLength(13); + expect(by('soccer')).toHaveLength(0); // no canonical registry → absent beats wrong + }); + + test('no duplicate abbr within a sport; every entry has name + abbr', () => { + const seen = new Set(); + for (const t of TEAMS) { + expect(Boolean(t.name && t.abbr && t.sport)).toBe(true); + const k = `${t.sport}|${t.abbr}`; + expect(seen.has(k)).toBe(false); + seen.add(k); + } + }); + + test('searchTeams matches name substring, mascot prefix, and abbr prefix', () => { + expect(searchTeams('yank')[0].name).toBe('New York Yankees'); + expect(searchTeams('NYY')[0].abbr).toBe('NYY'); + expect(searchTeams('tigers')[0].name).toBe('Detroit Tigers'); + expect(searchTeams('q')).toEqual([]); // under 2 chars → nothing + expect(searchTeams('zzzz')).toEqual([]); + }); + + test('teamHref → the S51 Team Hub route', () => { + expect(teamHref({ abbr: 'DET', sport: 'mlb' })).toBe('/team/DET?sport=mlb'); + }); +}); + +describe('SearchModal — grouped results, keyboard nav, canonical links', () => { + const src = read('components/vyndr/SearchModal.tsx'); + + test('players come from /api/players/search per sport (the canonical resolver)', () => { + expect(src).toContain('/api/players/search?sport='); + expect(src).toContain("['MLB', 'NBA', 'WNBA']"); + }); + test('grouped PLAYERS / TEAMS with arrow-key nav and Enter-to-open', () => { + expect(src).toContain('"PLAYERS"'); + expect(src).toContain('"TEAMS"'); + expect(src).toContain("e.key === 'ArrowDown'"); + expect(src).toContain("e.key === 'ArrowUp'"); + expect(src).toContain("e.key === 'Enter'"); + expect(src).toContain("e.key === 'Escape'"); + }); + test('destinations are the canonical builders (playerHref / teamHref)', () => { + expect(src).toContain("from '@/lib/playerHref'"); + expect(src).toContain('playerHref(name, res.sport)'); + expect(src).toContain('teamHref(t)'); + }); + test('debounced + stale-response guarded (never a flood, never a regression paint)', () => { + expect(src).toContain('250'); + expect(src).toContain('seqRef'); + }); +}); + +describe('entry points — ⌘K, Nav icon, mobile More sheet', () => { + test('GlobalHosts registers window.__search and the ⌘K/Ctrl-K shortcut', () => { + const src = read('components/vyndr/GlobalHosts.tsx'); + expect(src).toContain('window.__search'); + expect(src).toContain('e.metaKey || e.ctrlKey'); + expect(src).toContain("=== 'k'"); + expect(src).toContain(' { + const src = read('components/Nav.tsx'); + expect(src).toContain('window.__search && window.__search()'); + expect(src).toContain('aria-label="Search players and teams (⌘K)"'); + }); + + test('BottomTabBar: Search is the FIRST item in the More sheet; Explore tab stays', () => { + const src = read('components/BottomTabBar.tsx'); + const sheetSearch = src.indexOf('window.__search) window.__search()'); + const items = src.indexOf('{MORE_ITEMS.map((it) =>'); + expect(sheetSearch).toBeGreaterThan(-1); + expect(items).toBeGreaterThan(sheetSearch); // Search renders before the item list + expect(src).toContain("{ id: 'explore', label: 'Explore', href: '/explore'"); // tab NOT replaced + }); +}); diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 0dc18c5..4cd96c4 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -401,8 +401,13 @@ h1, h2, h3, h4, h5, h6 { animation: shimmer 1.5s linear infinite; } +/* S6 (A1 board, LCP) — floored at a VISIBLE state (.6), matching the S33 + entrance-keyframe rule (a paused frame is never invisible). Starting at + opacity 0 also delayed the hero heading's first contentful paint — an + invisible element isn't an LCP candidate until it becomes visible, so the + old 0-start pushed the landing LCP by the animation delay + duration. */ @keyframes fade-up { - 0% { opacity: 0; transform: translateY(8px); } + 0% { opacity: 0.6; transform: translateY(8px); } 100% { opacity: 1; transform: translateY(0); } } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 6704605..af29fb5 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -37,6 +37,12 @@ const ibmPlexMono = IBM_Plex_Mono({ weight: ['400', '500', '600', '700'], variable: '--font-ibm', display: 'swap', + // S6 (A1 board, LCP) — IBM Plex is the legacy/decorative mono (wordmark, + // hero sport chips); its FOUR weight files were 'd ahead + // of the render-critical CSS + Inter (the hero h1's face) on slow 4G. + // Not preloading them frees that bandwidth; display:swap still swaps them + // in when ready. Inter + JetBrains (the data face) stay preloaded. + preload: false, }); const fontVars = `${inter.variable} ${jetbrainsMono.variable} ${ibmPlexMono.variable}`; diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index feede8c..1d4ded0 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -45,6 +45,9 @@ interface LedgerRow { } interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null } +// S6 (A1 board) — settled-CLV distribution bucket. Server-side only when the +// n≥20 gate passes (null below — the gate lives in getModelAggregate). +interface ClvBucket { label: string; count: number; side: 'beat' | 'faded' | 'flat' } interface ModelAggregate { settled: number; hits: number; @@ -58,6 +61,7 @@ interface ModelAggregate { min_sample?: number; // Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier). by_tier?: Record; + clv_distribution?: ClvBucket[] | null; } const SPORT_COLOR: Record = { @@ -187,6 +191,48 @@ function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: n ); } +/** S6 (A1 board) — compact settled-CLV distribution strip. Bars are DATA + * (mono, tabular): green = beat-the-close side, red = faded side, dim = flat. + * Renders only when the server passed the n≥20 gate (clv_distribution set). */ +function ClvDistribution({ agg }: { agg: ModelAggregate }) { + const dist = agg.clv_distribution; + if (!Array.isArray(dist) || dist.length === 0) return null; + const total = dist.reduce((n, b) => n + b.count, 0); + if (total === 0) return null; + const max = Math.max(...dist.map((b) => b.count)); + const color = (side: ClvBucket['side']) => + side === 'beat' ? 'var(--g-a, #00D4A0)' : side === 'faded' ? 'var(--miss, #FF6B6B)' : 'var(--text-tertiary)'; + return ( +
+
+ CLV DISTRIBUTION · {total} SETTLED +
+
+ {dist.map((b) => ( +
+ 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}> + {b.count} + +
0 ? color(b.side) : 'var(--border)', + borderRadius: 2, + opacity: b.count > 0 ? 0.9 : 0.6, + }} + /> + + {b.label} + +
+ ))} +
+
+ ); +} + function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) { const ready = agg.settled >= minSample && agg.hit_pct != null; return ( @@ -227,6 +273,7 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe

)} + ); diff --git a/web/src/components/BottomTabBar.tsx b/web/src/components/BottomTabBar.tsx index dcf0e40..ceefa57 100644 --- a/web/src/components/BottomTabBar.tsx +++ b/web/src/components/BottomTabBar.tsx @@ -97,6 +97,38 @@ export default function BottomTabBar() {
+ {/* S6 (A1 board) — global search, first item in the sheet. + Same modal as ⌘K / the Nav icon (window.__search). */} + {MORE_ITEMS.map((it) => ( + {/* S6 (A1 board) — global search (players + teams). ⌘K opens the + same modal; this icon is the mobile/mouse path (window.__search + registered by GlobalHosts). */} + + {/* Preferences (language / odds format / accessibility) — §9/§10 */} + ); + })} +
+ ); + }; + + return ( +
+
e.stopPropagation()} + className="scanlines" + style={{ + width: '100%', + maxWidth: 560, + background: 'var(--bg-1)', + border: '1px solid var(--border-hi)', + borderRadius: 12, + overflow: 'hidden', + }} + > +
+ + setQ(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Search players, teams" + aria-label="Search players and teams" + className="mono" + style={{ + flex: 1, + background: 'transparent', + border: 'none', + outline: 'none', + color: 'var(--text-0, #fff)', + fontSize: 14, + letterSpacing: '0.02em', + }} + /> + + ESC + +
+
+ {q.trim().length < 2 ? ( +

+ Type two characters. ↑↓ to move, Enter to open. +

+ ) : flat.length === 0 ? ( +

+ No matches on the wire. +

+ ) : ( + <> + + + + )} +
+
+
+ ); +} diff --git a/web/src/components/vyndr/Sparkline.tsx b/web/src/components/vyndr/Sparkline.tsx index eb63366..57d9904 100644 --- a/web/src/components/vyndr/Sparkline.tsx +++ b/web/src/components/vyndr/Sparkline.tsx @@ -2,6 +2,10 @@ type SparklineProps = { data: number[]; /** Direction tint: green when up, red when down. */ up?: boolean; + /** S6 (A1) — explicit stroke override (a CSS color/var). Takes precedence + * over `up`. ROW-GRAMMAR color law: line movement is green (toward) / + * amber (against) / dim (flat) — red is settled-negative only. */ + color?: string; w?: number; h?: number; }; @@ -10,7 +14,7 @@ type SparklineProps = { * Tiny SVG line for line movement (§5). Green up / red down, dot on the last * point. Dependency-free. Data viz — the line itself never glitches. */ -export default function Sparkline({ data, up = true, w = 92, h = 26 }: SparklineProps) { +export default function Sparkline({ data, up = true, color, w = 92, h = 26 }: SparklineProps) { const safe = data && data.length ? data : [0, 0]; const min = Math.min(...safe); const max = Math.max(...safe); @@ -21,7 +25,7 @@ export default function Sparkline({ data, up = true, w = 92, h = 26 }: Sparkline return [x, y] as const; }); const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' '); - const c = up ? 'var(--g-a)' : 'var(--miss)'; + const c = color || (up ? 'var(--g-a)' : 'var(--miss)'); const last = pts[pts.length - 1]; return ( diff --git a/web/src/components/vyndr/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx index 9be23eb..81885a7 100644 --- a/web/src/components/vyndr/StatStrip.tsx +++ b/web/src/components/vyndr/StatStrip.tsx @@ -1,5 +1,6 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import GradeBadge from '@/components/vyndr/GradeBadge'; +import Sparkline from '@/components/vyndr/Sparkline'; import { nextRunLabelET } from '@/lib/pipelineSchedule'; // A1 S3 — BOOK IT is a real per-book deep link now (organic until the // affiliate config flips a book on). rel MUST stay BOOK_LINK_REL. @@ -31,6 +32,52 @@ export interface StripProp { // Session 64 (A1-S5) — NOT-IN-LINEUP kills the grade display (struck // through, actions suppressed). The locked ledger read is untouched. dead?: boolean; + // S6 (A1 board) — ROW-GRAMMAR sub-line data. `history` = REAL captured + // {t, line} points from the intraday refresh (sparkline needs ≥3); + // `last10Dots` = last-10 games vs tonight's locked line, newest first + // (true = cleared). Both absent → nothing renders. + history?: Array<{ t: string; line: number }> | null; + last10Dots?: boolean[] | null; +} + +/** ROW-GRAMMAR §4 — ●/○ last-10 dot strip. Filled green = that game's stat + * cleared TONIGHT'S locked line; hollow dim = it didn't. Newest first. + * Real game logs only — no log, no dots. */ +export function DotStrip({ dots }: { dots?: boolean[] | null }) { + if (!Array.isArray(dots) || dots.length === 0) return null; + const hits = dots.filter(Boolean).length; + return ( + + {dots.map((hit, i) => ( + + {hit ? '●' : '○'} + + ))} + + ); +} + +/** ROW-GRAMMAR §4 — line-movement sparkline from REAL captured {t, line} + * points. Renders only at ≥3 points. Color law: green = net move TOWARD the + * graded side, amber = net AGAINST, dim = flat. Never red — nothing settled. */ +export function LineSparkline({ p }: { p: StripProp }) { + const h = p.history; + if (!Array.isArray(h) || h.length < 3) return null; + const lines = h.map((pt) => pt && pt.line).filter((v): v is number => typeof v === 'number' && Number.isFinite(v)); + if (lines.length < 3) return null; + const raw = lines[lines.length - 1] - lines[0]; + const toward = String(p.side || 'O').toUpperCase().startsWith('U') ? -raw : raw; + const color = toward > 0 ? 'var(--g-a)' : toward < 0 ? 'var(--amber)' : 'var(--text-1)'; + return ( + + + + ); } /** Session 64 (A1-S5) — lineup + injury viability chips (real feeds only). */ @@ -270,8 +317,6 @@ export default function StatStrip({
{player} {team} - {/* Session 64 (A1-S5) — lineup confirmation + injury wire chips. */} - {archetype && } {archetype?.secondary && ( <> @@ -279,6 +324,9 @@ export default function StatStrip({ )} + {/* ROW-GRAMMAR §2 — identity is one contiguous run (name → team → + archetype); viability status chips FOLLOW it. Session 64 chips. */} +
{stats.map((s, i) => ( @@ -327,10 +375,14 @@ export default function StatStrip({ const toward = p.delta?.direction === 'toward'; return (
-
+
{p.stat} {p.side}{p.line} + {/* ROW-GRAMMAR slot 4 — market context: best-price dot, + then movement chip, BEFORE any model output. */} - {/* Phase 2.5 — a revised grade is PUBLIC: original struck through. */} + {!p.dead && } + {/* ROW-GRAMMAR slot 5 — model output. Phase 2.5: a revised + grade is PUBLIC — original struck through, then current. */} {p.revisedFrom && ( {p.revisedFrom} )} @@ -346,7 +398,6 @@ export default function StatStrip({ ) )} - {!p.dead && } {!p.outcome && !p.dead && } {!p.outcome && !p.dead && } @@ -356,11 +407,20 @@ export default function StatStrip({ )}
- {p.delta && ( -
- Current {p.delta.currentLine} · {p.delta.delta > 0 ? '▲' : '▼'} {toward ? 'TOWARD' : 'AWAY'} {p.delta.delta > 0 ? '+' : ''}{p.delta.delta} + {/* ROW-GRAMMAR sub-line — stat + market context, fixed order: + last-10 dots · line sparkline · current-line delta. Each + renders only on real data; all absent → no sub-line. */} + {(p.last10Dots?.length || (p.history && p.history.length >= 3) || p.delta) ? ( +
+ + + {p.delta && ( + + Current {p.delta.currentLine} · {p.delta.delta > 0 ? '▲' : '▼'} {toward ? 'TOWARD' : 'AWAY'} {p.delta.delta > 0 ? '+' : ''}{p.delta.delta} + + )}
- )} + ) : null}
); })} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index db85104..7cab8dd 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -25,6 +25,10 @@ export { default as StatStrip } from './StatStrip'; export type { StatCell, StripProp, StripArchetype } from './StatStrip'; export { default as BookChip } from './BookChip'; +/* S6 (A1 board) — global search + row-grammar micro-marks */ +export { default as SearchModal } from './SearchModal'; +export { DotStrip, LineSparkline } from './StatStrip'; + export { GRADE_COLORS, GRADE_HEX, diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index d43e18d..03be4ce 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -384,6 +384,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat // + the ORIGINAL grade when a public revision happened. movement: rec.movement || null, revisedFrom: rec.revised_from_grade || null, + // S6 (A1 board, ROW-GRAMMAR sub-line) — real captured line history + // (sparkline, ≥3 points) + last-10 ●/○ vs tonight's locked line. + history: Array.isArray(rec.history) && rec.history.length > 0 ? rec.history : null, + last10Dots: Array.isArray(rec.last10_dots) && rec.last10_dots.length > 0 ? rec.last10_dots : null, }); } else { byPlayer[pk].props.push({ diff --git a/web/src/lib/teams.js b/web/src/lib/teams.js new file mode 100644 index 0000000..19addae --- /dev/null +++ b/web/src/lib/teams.js @@ -0,0 +1,119 @@ +/* ============================================================ + S6 (A1 board) — static team registry for global search. + League membership is a stable public fact (not market data), so a + static list is honest. CommonJS so the Jest suite requires it and + the .tsx SearchModal imports it (allowJs). + + Soccer is deliberately ABSENT: the product has no canonical soccer + team registry (no soccer team-hub branch, no fixed competition set) + — absent beats wrong. Add it when the soccer hub lands. + + Team links go to /team/:abbr?sport= (the S51 Team Hub). MLB abbrs + match statsapi (AZ, CWS, ATH); NBA/WNBA hubs render the partial + graded-roster view until a free roster feed exists. + ============================================================ */ + +const TEAMS = [ + // ── MLB (30) ────────────────────────────────────────────── + { sport: 'mlb', abbr: 'AZ', name: 'Arizona Diamondbacks' }, + { sport: 'mlb', abbr: 'ATL', name: 'Atlanta Braves' }, + { sport: 'mlb', abbr: 'BAL', name: 'Baltimore Orioles' }, + { sport: 'mlb', abbr: 'BOS', name: 'Boston Red Sox' }, + { sport: 'mlb', abbr: 'CHC', name: 'Chicago Cubs' }, + { sport: 'mlb', abbr: 'CWS', name: 'Chicago White Sox' }, + { sport: 'mlb', abbr: 'CIN', name: 'Cincinnati Reds' }, + { sport: 'mlb', abbr: 'CLE', name: 'Cleveland Guardians' }, + { sport: 'mlb', abbr: 'COL', name: 'Colorado Rockies' }, + { sport: 'mlb', abbr: 'DET', name: 'Detroit Tigers' }, + { sport: 'mlb', abbr: 'HOU', name: 'Houston Astros' }, + { sport: 'mlb', abbr: 'KC', name: 'Kansas City Royals' }, + { sport: 'mlb', abbr: 'LAA', name: 'Los Angeles Angels' }, + { sport: 'mlb', abbr: 'LAD', name: 'Los Angeles Dodgers' }, + { sport: 'mlb', abbr: 'MIA', name: 'Miami Marlins' }, + { sport: 'mlb', abbr: 'MIL', name: 'Milwaukee Brewers' }, + { sport: 'mlb', abbr: 'MIN', name: 'Minnesota Twins' }, + { sport: 'mlb', abbr: 'NYM', name: 'New York Mets' }, + { sport: 'mlb', abbr: 'NYY', name: 'New York Yankees' }, + { sport: 'mlb', abbr: 'ATH', name: 'Athletics' }, + { sport: 'mlb', abbr: 'PHI', name: 'Philadelphia Phillies' }, + { sport: 'mlb', abbr: 'PIT', name: 'Pittsburgh Pirates' }, + { sport: 'mlb', abbr: 'SD', name: 'San Diego Padres' }, + { sport: 'mlb', abbr: 'SF', name: 'San Francisco Giants' }, + { sport: 'mlb', abbr: 'SEA', name: 'Seattle Mariners' }, + { sport: 'mlb', abbr: 'STL', name: 'St. Louis Cardinals' }, + { sport: 'mlb', abbr: 'TB', name: 'Tampa Bay Rays' }, + { sport: 'mlb', abbr: 'TEX', name: 'Texas Rangers' }, + { sport: 'mlb', abbr: 'TOR', name: 'Toronto Blue Jays' }, + { sport: 'mlb', abbr: 'WSH', name: 'Washington Nationals' }, + // ── NBA (30) ────────────────────────────────────────────── + { sport: 'nba', abbr: 'ATL', name: 'Atlanta Hawks' }, + { sport: 'nba', abbr: 'BOS', name: 'Boston Celtics' }, + { sport: 'nba', abbr: 'BKN', name: 'Brooklyn Nets' }, + { sport: 'nba', abbr: 'CHA', name: 'Charlotte Hornets' }, + { sport: 'nba', abbr: 'CHI', name: 'Chicago Bulls' }, + { sport: 'nba', abbr: 'CLE', name: 'Cleveland Cavaliers' }, + { sport: 'nba', abbr: 'DAL', name: 'Dallas Mavericks' }, + { sport: 'nba', abbr: 'DEN', name: 'Denver Nuggets' }, + { sport: 'nba', abbr: 'DET', name: 'Detroit Pistons' }, + { sport: 'nba', abbr: 'GSW', name: 'Golden State Warriors' }, + { sport: 'nba', abbr: 'HOU', name: 'Houston Rockets' }, + { sport: 'nba', abbr: 'IND', name: 'Indiana Pacers' }, + { sport: 'nba', abbr: 'LAC', name: 'LA Clippers' }, + { sport: 'nba', abbr: 'LAL', name: 'Los Angeles Lakers' }, + { sport: 'nba', abbr: 'MEM', name: 'Memphis Grizzlies' }, + { sport: 'nba', abbr: 'MIA', name: 'Miami Heat' }, + { sport: 'nba', abbr: 'MIL', name: 'Milwaukee Bucks' }, + { sport: 'nba', abbr: 'MIN', name: 'Minnesota Timberwolves' }, + { sport: 'nba', abbr: 'NOP', name: 'New Orleans Pelicans' }, + { sport: 'nba', abbr: 'NYK', name: 'New York Knicks' }, + { sport: 'nba', abbr: 'OKC', name: 'Oklahoma City Thunder' }, + { sport: 'nba', abbr: 'ORL', name: 'Orlando Magic' }, + { sport: 'nba', abbr: 'PHI', name: 'Philadelphia 76ers' }, + { sport: 'nba', abbr: 'PHX', name: 'Phoenix Suns' }, + { sport: 'nba', abbr: 'POR', name: 'Portland Trail Blazers' }, + { sport: 'nba', abbr: 'SAC', name: 'Sacramento Kings' }, + { sport: 'nba', abbr: 'SAS', name: 'San Antonio Spurs' }, + { sport: 'nba', abbr: 'TOR', name: 'Toronto Raptors' }, + { sport: 'nba', abbr: 'UTA', name: 'Utah Jazz' }, + { sport: 'nba', abbr: 'WAS', name: 'Washington Wizards' }, + // ── WNBA (13) ───────────────────────────────────────────── + { sport: 'wnba', abbr: 'ATL', name: 'Atlanta Dream' }, + { sport: 'wnba', abbr: 'CHI', name: 'Chicago Sky' }, + { sport: 'wnba', abbr: 'CON', name: 'Connecticut Sun' }, + { sport: 'wnba', abbr: 'DAL', name: 'Dallas Wings' }, + { sport: 'wnba', abbr: 'GSV', name: 'Golden State Valkyries' }, + { sport: 'wnba', abbr: 'IND', name: 'Indiana Fever' }, + { sport: 'wnba', abbr: 'LVA', name: 'Las Vegas Aces' }, + { sport: 'wnba', abbr: 'LAS', name: 'Los Angeles Sparks' }, + { sport: 'wnba', abbr: 'MIN', name: 'Minnesota Lynx' }, + { sport: 'wnba', abbr: 'NYL', name: 'New York Liberty' }, + { sport: 'wnba', abbr: 'PHX', name: 'Phoenix Mercury' }, + { sport: 'wnba', abbr: 'SEA', name: 'Seattle Storm' }, + { sport: 'wnba', abbr: 'WAS', name: 'Washington Mystics' }, +]; + +/** /team/:abbr?sport= — the canonical Team Hub link (S51). */ +function teamHref(team) { + return `/team/${encodeURIComponent(team.abbr)}?sport=${encodeURIComponent(team.sport)}`; +} + +/** + * Case-insensitive team match: full name substring, mascot (last word) + * prefix, or abbreviation prefix. Returns [] under 2 chars. + */ +function searchTeams(q, limit = 6) { + const s = String(q || '').trim().toLowerCase(); + if (s.length < 2) return []; + const out = []; + for (const t of TEAMS) { + const name = t.name.toLowerCase(); + const mascot = name.split(' ').pop() || ''; + if (name.includes(s) || mascot.startsWith(s) || t.abbr.toLowerCase().startsWith(s)) { + out.push(t); + if (out.length >= limit) break; + } + } + return out; +} + +module.exports = { TEAMS, searchTeams, teamHref };