S6 (a1): display — the full picture under the grammar

- 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 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 20:08:24 -04:00
parent 1d46b446c9
commit d3637e7abd
26 changed files with 1408 additions and 25 deletions
+18
View File
@@ -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` + 'push', actual:number }` once the game is final. `AccuracyBadge` +
`StatStrip.OutcomeChip` render these. Frontend proxy: `web/src/app/api/accuracy`. `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) ## 8. Public ledger profiles (`src/routes/profiles.js`, A1 Session 10)
Strava-for-betting v1. Table `public_profiles` (migration 022 — apply Strava-for-betting v1. Table `public_profiles` (migration 022 — apply
+29
View File
@@ -3,6 +3,35 @@
## Last Updated ## Last Updated
2026-07-11 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 ✅ ## 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). Spec: `specs/a1-s9-slip-reader.md`. +34 tests (24 unit + 10 integration).
- **slipReader service** — tesseract.js (self-hosted WASM, Apache-2.0, zero - **slipReader service** — tesseract.js (self-hosted WASM, Apache-2.0, zero
+33
View File
@@ -934,6 +934,39 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
`npx jest --testPathIgnorePatterns "/node_modules/"` from a worktree `npx jest --testPathIgnorePatterns "/node_modules/"` from a worktree
(CLI replaces the config array). (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 ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+86
View File
@@ -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 —
+9 -3
View File
@@ -13,6 +13,9 @@ const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit'); const { createRateLimit } = require('../middleware/rateLimit');
const { cacheGet } = require('../utils/redis'); const { cacheGet } = require('../utils/redis');
const { nameKey } = require('../utils/playerName'); 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(); const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 })); router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
@@ -79,20 +82,23 @@ router.get('/summary', async (req, res) => {
router.get('/:sport', async (req, res) => { router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase(); const sport = String(req.params.sport || '').toLowerCase();
try { try {
const [snap, outcomeLog] = await Promise.all([ const [snap, outcomeLog, rosterBlob] = await Promise.all([
cacheGet(`snapshot:${sport}:latest`), cacheGet(`snapshot:${sport}:latest`),
cacheGet(`outcomes:${sport}:log`), cacheGet(`outcomes:${sport}:log`),
cacheGet(`rosterlogs:${sport}`),
]); ]);
const idx = outcomeIndex(outcomeLog); const idx = outcomeIndex(outcomeLog);
const roster = indexRosterLogs(rosterBlob);
const enrich = (grades) => attachLast10Dots(attachOutcomes(grades, idx), roster, sport);
if (snap && Array.isArray(snap.grades)) { if (snap && Array.isArray(snap.grades)) {
res.set('Cache-Control', 'public, max-age=30'); 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). // Fallback: the grades envelope (no deltas yet).
const env = await cacheGet(`grades:${sport}`); const env = await cacheGet(`grades:${sport}`);
const grades = env && Array.isArray(env.grades) ? env.grades : []; const grades = env && Array.isArray(env.grades) ? env.grades : [];
res.set('Cache-Control', 'public, max-age=30'); 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) { } catch (err) {
console.error('[snapshot]', err.message); console.error('[snapshot]', err.message);
return res.status(200).json({ sport, grades: [], deltas: [] }); return res.status(200).json({ sport, grades: [], deltas: [] });
+43 -7
View File
@@ -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 REGRADE_TRIGGER = 1.0; // moved-against threshold that triggers a re-grade
const SNAP_TTL = 24 * 3600; // keep in sync with snapshotService const SNAP_TTL = 24 * 3600; // keep in sync with snapshotService
const TICKER_MOVE_CAP = 6; 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 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); const rank = (g) => (g && GRADE_RANK[g] !== undefined ? GRADE_RANK[g] : 99);
@@ -52,6 +53,36 @@ function indexOddsProps(props) {
return map; 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). /** Signed movement RELATIVE TO THE GRADED SIDE: positive = toward (with).
* Strict null-safe parse — Number(null) is 0, a fabricated line. */ * Strict null-safe parse — Number(null) is 0, a fabricated line. */
function signedDelta(side, lockedLine, currentLine) { 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; } if (!prop || prop.line == null || locked == null) { grades.push(g); continue; }
checked += 1; 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); const delta = signedDelta(g.direction, locked, prop.line);
if (delta == null || Math.abs(delta) < STEAM_NOISE) { if (delta == null || Math.abs(delta) < STEAM_NOISE) {
grades.push({ ...g, movement: null }); grades.push(withHist({ ...g, movement: null }));
continue; continue;
} }
@@ -120,7 +156,7 @@ async function runIntradayRefresh(sport, opts = {}) {
if (delta > 0) { if (delta > 0) {
// Moved WITH the grade — the market is chasing our number. // Moved WITH the grade — the market is chasing our number.
steam += 1; 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) { if (Math.abs(delta) >= REGRADE_TRIGGER) {
moveEvents.push(moveEvent(sp, g, locked, current, ts)); moveEvents.push(moveEvent(sp, g, locked, current, ts));
} }
@@ -129,7 +165,7 @@ async function runIntradayRefresh(sport, opts = {}) {
// Moved AGAINST the grade. // Moved AGAINST the grade.
if (Math.abs(delta) < REGRADE_TRIGGER) { 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; continue;
} }
@@ -146,19 +182,19 @@ async function runIntradayRefresh(sport, opts = {}) {
if (!res || !res.grade || res.insufficient_data || rank(res.grade) <= rank(g.grade)) { 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. // Grade holds (or the model refuses to re-read) → better entry, same read.
value += 1; 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; continue;
} }
// Grade DROPS → public revision. Original grade preserved once, forever. // Grade DROPS → public revision. Original grade preserved once, forever.
revised += 1; revised += 1;
const fromGrade = g.revised_from_grade || g.grade; const fromGrade = g.revised_from_grade || g.grade;
grades.push({ grades.push(withHist({
...g, ...g,
grade: res.grade, grade: res.grade,
revised_from_grade: fromGrade, revised_from_grade: fromGrade,
movement: { kind: 'revised', delta, currentLine: current, at: ts }, movement: { kind: 'revised', delta, currentLine: current, at: ts },
}); }));
try { try {
await deps.ledger.applyRevision(sp, { await deps.ledger.applyRevision(sp, {
playerKey: nameKey(player), stat, line: Number(locked), playerKey: nameKey(player), stat, line: Number(locked),
@@ -213,5 +249,5 @@ module.exports = {
runIntradayRefresh, runIntradayRefresh,
runAllIntradayRefreshes, runAllIntradayRefreshes,
inSlateHours, inSlateHours,
__internals: { signedDelta, indexOddsProps, moveEvent, rank, STEAM_NOISE, REGRADE_TRIGGER }, __internals: { signedDelta, indexOddsProps, moveEvent, rank, trackHistory, STEAM_NOISE, REGRADE_TRIGGER, HISTORY_CAP },
}; };
+109
View File
@@ -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 },
};
+47 -2
View File
@@ -38,6 +38,37 @@ const AGG_FETCH_LIMIT = 5000;
/** Below this many settled rows, callers must not render a percentage. */ /** Below this many settled rows, callers must not render a percentage. */
const MIN_AGG_SAMPLE = 20; 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() { function isConfigured() {
return Boolean(process.env.SUPABASE_URL return Boolean(process.env.SUPABASE_URL
&& (process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY)); && (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, window_days: AGG_WINDOW_DAYS, min_sample: MIN_AGG_SAMPLE,
settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null, 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_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, pending: 0,
}; };
if (!opts.sb && !isConfigured()) return empty; 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); const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
let settledQ = sb.from('ledger_entries') 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 = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null);
settledQ = settledQ settledQ = settledQ
.not('outcome', 'is', null) .not('outcome', 'is', null)
@@ -471,6 +503,19 @@ async function getModelAggregate(opts = {}) {
if (agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) { if (agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) {
agg.beat_close_pct = Math.round((agg.clv_beat / agg.clv_sample) * 100); 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; return agg;
} }
@@ -486,6 +531,6 @@ module.exports = {
__internals: { __internals: {
rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor, rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor,
dateET, sideOf, oddsForSide, isConfigured, CONFLICT, dateET, sideOf, oddsForSide, isConfigured, CONFLICT,
teamOpponentFor, teamsMatch, teamOpponentFor, teamsMatch, clvBucketIndex, CLV_BUCKETS,
}, },
}; };
+6
View File
@@ -61,6 +61,12 @@ const mlb = {
strikeouts: (r) => num(r, 'strikeOuts', 'strikeouts', 'pitcherK', 'K', 'so'), strikeouts: (r) => num(r, 'strikeOuts', 'strikeouts', 'pitcherK', 'K', 'so'),
inningsPitched: (r) => num(r, 'inningsPitched', 'ip', 'IP'), inningsPitched: (r) => num(r, 'inningsPitched', 'ip', 'IP'),
earnedRuns: (r) => num(r, 'earnedRuns', 'er', 'ER'), 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.onBase = (r) => mlb.hits(r) + mlb.walks(r) + mlb.hbp(r);
mlb.isQualityStart = (r) => mlb.inningsPitched(r) >= 6 && mlb.earnedRuns(r) <= 3; mlb.isQualityStart = (r) => mlb.inningsPitched(r) >= 6 && mlb.earnedRuns(r) <= 3;
+62 -1
View File
@@ -4,7 +4,7 @@
// real feed values; the refresh recaptures the close. // real feed values; the refresh recaptures the close.
const { runIntradayRefresh, inSlateHours, __internals } = require('../../src/services/intradayRefreshService'); 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'; 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 — noonmidnight ET', () => { describe('inSlateHours — noonmidnight ET', () => {
test('3 PM ET yes, 4 AM ET no', () => { test('3 PM ET yes, 4 AM ET no', () => {
expect(inSlateHours(new Date('2026-07-11T19:00:00Z'))).toBe(true); // 3 PM EDT expect(inSlateHours(new Date('2026-07-11T19:00:00Z'))).toBe(true); // 3 PM EDT
+96
View File
@@ -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 });
}
}
});
});
+61
View File
@@ -271,3 +271,64 @@ describe('indexProps — prefers a book row with both sides priced', () => {
expect(idx['a guy|hits'].book).toBe('fanduel'); 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
});
});
+121
View File
@@ -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("<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>");
expect(base).toBeGreaterThan(-1);
assertOrder(strip, [
'<BestPriceDot p={p} />', // slot 4a — best available price
'<MovementChip p={p} />', // slot 4b — market movement
'p.revisedFrom', // slot 5a — revision strikethrough
'<GradeBadge grade={p.grade}', // slot 5b — the current grade
'<OutcomeChip p={p} />', // slot 6 — settled result
'<ParlayBtn p={p} />', // slot 7a — action
'<BookItTeaser p={p} />', // 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, [
'<DotStrip dots={p.last10Dots} />',
'<LineSparkline p={p} />',
'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, [
'<PlayerName',
'{team}</span>',
'<ArchetypeBadge archetype={archetype.primary}',
'<ViabilityChips lineup={lineup} injury={injury} />',
], base);
});
test('settled/dead rows suppress actions (the bet is over)', () => {
expect(strip).toContain('{!p.outcome && !p.dead && <ParlayBtn p={p} />}');
expect(strip).toContain('{!p.outcome && !p.dead && <BookItTeaser p={p} />}');
});
});
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"');
});
});
+92
View File
@@ -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('<SearchModal');
expect(src).toContain("removeEventListener('keydown'");
});
test('Nav keeps a search icon button (the mobile/mouse path)', () => {
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
});
});
+6 -1
View File
@@ -401,8 +401,13 @@ h1, h2, h3, h4, h5, h6 {
animation: shimmer 1.5s linear infinite; 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 { @keyframes fade-up {
0% { opacity: 0; transform: translateY(8px); } 0% { opacity: 0.6; transform: translateY(8px); }
100% { opacity: 1; transform: translateY(0); } 100% { opacity: 1; transform: translateY(0); }
} }
+6
View File
@@ -37,6 +37,12 @@ const ibmPlexMono = IBM_Plex_Mono({
weight: ['400', '500', '600', '700'], weight: ['400', '500', '600', '700'],
variable: '--font-ibm', variable: '--font-ibm',
display: 'swap', display: 'swap',
// S6 (A1 board, LCP) — IBM Plex is the legacy/decorative mono (wordmark,
// hero sport chips); its FOUR weight files were <link rel=preload>'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}`; const fontVars = `${inter.variable} ${jetbrainsMono.variable} ${ibmPlexMono.variable}`;
+47
View File
@@ -45,6 +45,9 @@ interface LedgerRow {
} }
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null } 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 { interface ModelAggregate {
settled: number; settled: number;
hits: number; hits: number;
@@ -58,6 +61,7 @@ interface ModelAggregate {
min_sample?: number; min_sample?: number;
// Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier). // Session 60 (5.5) — calibration by grade tier (n≥20 rule per tier).
by_tier?: Record<string, TierRecord>; by_tier?: Record<string, TierRecord>;
clv_distribution?: ClvBucket[] | null;
} }
const SPORT_COLOR: Record<string, string> = { const SPORT_COLOR: Record<string, string> = {
@@ -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 (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div className="mono" style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
CLV DISTRIBUTION · {total} SETTLED
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', maxWidth: 420 }}>
{dist.map((b) => (
<div key={b.label} title={`${b.label}: ${b.count}`} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, minWidth: 0 }}>
<span className="mono" style={{ fontSize: 10, fontVariantNumeric: 'tabular-nums', color: b.count > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}>
{b.count}
</span>
<div
aria-label={`${b.label}: ${b.count} settled`}
style={{
width: '100%',
height: Math.max(3, Math.round((b.count / max) * 40)),
background: b.count > 0 ? color(b.side) : 'var(--border)',
borderRadius: 2,
opacity: b.count > 0 ? 0.9 : 0.6,
}}
/>
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '0.02em', color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
{b.label}
</span>
</div>
))}
</div>
</div>
);
}
function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) { function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
const ready = agg.settled >= minSample && agg.hit_pct != null; const ready = agg.settled >= minSample && agg.hit_pct != null;
return ( return (
@@ -227,6 +273,7 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
</p> </p>
</div> </div>
)} )}
<ClvDistribution agg={agg} />
<TierCalibration agg={agg} minSample={minSample} /> <TierCalibration agg={agg} minSample={minSample} />
</div> </div>
); );
+32
View File
@@ -97,6 +97,38 @@ export default function BottomTabBar() {
</button> </button>
</div> </div>
<div> <div>
{/* S6 (A1 board) — global search, first item in the sheet.
Same modal as ⌘K / the Nav icon (window.__search). */}
<button
type="button"
onClick={() => {
setMoreOpen(false);
if (typeof window !== 'undefined' && window.__search) window.__search();
}}
className="mono"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
minHeight: 48,
padding: '0 18px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: 'var(--g-a)',
fontSize: 13,
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
borderBottom: '1px solid var(--border)',
}}
>
Search
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" />
</svg>
</button>
{MORE_ITEMS.map((it) => ( {MORE_ITEMS.map((it) => (
<a <a
key={it.href} key={it.href}
+14
View File
@@ -190,6 +190,20 @@ export default function Nav() {
the "Query" pill was a duplicate link to /scan — deleted. A real the "Query" pill was a duplicate link to /scan — deleted. A real
⌘K palette is a later nicety (spec §4), not a nav link. */} ⌘K palette is a later nicety (spec §4), not a nav link. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{/* S6 (A1 board) — global search (players + teams). ⌘K opens the
same modal; this icon is the mobile/mouse path (window.__search
registered by GlobalHosts). */}
<button
onClick={() => typeof window !== 'undefined' && window.__search && window.__search()}
aria-label="Search players and teams (⌘K)"
title="Search — ⌘K"
style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid var(--border-hi)', background: 'var(--bg-2)', color: 'var(--text-1)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="11" cy="11" r="7" /><path d="M21 21l-4.35-4.35" />
</svg>
</button>
{/* Preferences (language / odds format / accessibility) — §9/§10 */} {/* Preferences (language / odds format / accessibility) — §9/§10 */}
<button <button
onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()} onClick={() => typeof window !== 'undefined' && window.__prefs && window.__prefs()}
+17
View File
@@ -6,6 +6,8 @@ import { REGIONS, ODDS_FORMATS, regionPreset } from '@/lib/oddsFormat';
import { checkoutUrl, PLAN_PRICES } from '@/lib/checkout'; import { checkoutUrl, PLAN_PRICES } from '@/lib/checkout';
import SectionHead from '@/components/vyndr/SectionHead'; import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn'; import VBtn from '@/components/vyndr/VBtn';
// S6 (A1 board) — global player/team search (⌘K + window.__search).
import SearchModal from '@/components/vyndr/SearchModal';
// Augment the window with the design's global hosts (§12). // Augment the window with the design's global hosts (§12).
declare global { declare global {
@@ -13,6 +15,7 @@ declare global {
__prefs?: () => void; __prefs?: () => void;
__goPaywall?: () => void; __goPaywall?: () => void;
__checkout?: (plan: string) => void; __checkout?: (plan: string) => void;
__search?: () => void;
} }
} }
@@ -28,6 +31,7 @@ export default function GlobalHosts() {
const [prefs, setPrefs] = useState<Prefs>(DEFAULTS); const [prefs, setPrefs] = useState<Prefs>(DEFAULTS);
const [prefsOpen, setPrefsOpen] = useState(false); const [prefsOpen, setPrefsOpen] = useState(false);
const [paywallOpen, setPaywallOpen] = useState(false); const [paywallOpen, setPaywallOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => { useEffect(() => {
const loaded = loadPrefs(); const loaded = loadPrefs();
@@ -38,10 +42,22 @@ export default function GlobalHosts() {
window.__checkout = (plan: string) => { window.__checkout = (plan: string) => {
window.location.href = checkoutUrl(plan); window.location.href = checkoutUrl(plan);
}; };
// S6 — global search: window.__search (Nav icon + mobile More sheet)
// and the ⌘K / Ctrl-K shortcut.
window.__search = () => setSearchOpen(true);
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === 'k') {
e.preventDefault();
setSearchOpen((o) => !o);
}
};
window.addEventListener('keydown', onKey);
return () => { return () => {
delete window.__prefs; delete window.__prefs;
delete window.__goPaywall; delete window.__goPaywall;
delete window.__checkout; delete window.__checkout;
delete window.__search;
window.removeEventListener('keydown', onKey);
}; };
}, []); }, []);
@@ -63,6 +79,7 @@ export default function GlobalHosts() {
<> <>
{prefsOpen && <PrefsModal prefs={prefs} update={update} onClose={() => setPrefsOpen(false)} />} {prefsOpen && <PrefsModal prefs={prefs} update={update} onClose={() => setPrefsOpen(false)} />}
{paywallOpen && <PaywallModal onClose={() => setPaywallOpen(false)} />} {paywallOpen && <PaywallModal onClose={() => setPaywallOpen(false)} />}
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
</> </>
); );
} }
+272
View File
@@ -0,0 +1,272 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { playerHref } from '@/lib/playerHref';
import { searchTeams, teamHref } from '@/lib/teams';
/**
* SearchModal — S6 (A1 board). Global search over players + teams.
*
* Players: /api/players/search per sport (the canonical nameKey resolver,
* S60 — MLB statsapi list, others cache-only). Debounced 250ms.
* Teams: the static registry in lib/teams.js — no fetch.
*
* ⌘K / Ctrl-K opens it (registered in GlobalHosts, which also exposes
* window.__search for the Nav icon + the mobile More sheet). Grouped results
* (PLAYERS / TEAMS), arrow-key navigation, Enter → playerHref / team hub.
* Data is mono; chrome may glitch, results never do.
*/
const PLAYER_SPORTS = ['MLB', 'NBA', 'WNBA'] as const;
const SPORT_COLOR: Record<string, string> = {
mlb: 'var(--s-mlb, #1E90FF)',
nba: 'var(--s-nba, #E94B3C)',
wnba: 'var(--s-wnba, #FFB347)',
};
interface ResultItem {
kind: 'player' | 'team';
label: string;
sub: string; // team / abbr context
sport: string;
href: string;
}
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const router = useRouter();
const [q, setQ] = useState('');
const [players, setPlayers] = useState<ResultItem[]>([]);
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const seqRef = useRef(0);
// Teams are a static-list match — synchronous, no fetch.
const teams = useMemo<ResultItem[]>(
() =>
searchTeams(q, 6).map((t: { name: string; abbr: string; sport: string }) => ({
kind: 'team' as const,
label: t.name,
sub: t.abbr,
sport: t.sport,
href: teamHref(t),
})),
[q],
);
const flat = useMemo(() => [...players, ...teams], [players, teams]);
// Debounced player fan-out. A stale response never overwrites a newer one.
useEffect(() => {
if (!open) return;
const query = q.trim();
if (query.length < 2) {
setPlayers([]);
return;
}
const seq = ++seqRef.current;
const timer = setTimeout(async () => {
try {
const responses = await Promise.all(
PLAYER_SPORTS.map((sp) =>
fetch(`/api/players/search?sport=${sp}&q=${encodeURIComponent(query)}`)
.then((r) => (r.ok ? r.json() : { players: [] }))
.then((d) => ({ sport: sp.toLowerCase(), players: Array.isArray(d?.players) ? d.players : [] }))
.catch(() => ({ sport: sp.toLowerCase(), players: [] })),
),
);
if (seq !== seqRef.current) return;
const seen = new Set<string>();
const merged: ResultItem[] = [];
for (const res of responses) {
for (const p of res.players as Array<{ full_name?: string; team?: string }>) {
const name = String(p.full_name || '').trim();
if (!name) continue;
const key = `${name.toLowerCase()}|${res.sport}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push({
kind: 'player',
label: name,
sub: p.team ? String(p.team) : '',
sport: res.sport,
href: playerHref(name, res.sport),
});
if (merged.length >= 9) break;
}
if (merged.length >= 9) break;
}
setPlayers(merged);
} catch {
if (seq === seqRef.current) setPlayers([]);
}
}, 250);
return () => clearTimeout(timer);
}, [q, open]);
// Reset + focus on open.
useEffect(() => {
if (open) {
setQ('');
setPlayers([]);
setSel(0);
const t = setTimeout(() => inputRef.current?.focus(), 20);
return () => clearTimeout(t);
}
}, [open]);
useEffect(() => {
setSel(0);
}, [q]);
const go = useCallback(
(item: ResultItem | undefined) => {
if (!item) return;
onClose();
router.push(item.href);
},
[onClose, router],
);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSel((s) => Math.min(s + 1, Math.max(flat.length - 1, 0)));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
go(flat[sel]);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
if (!open) return null;
const Group = ({ title, items, offset }: { title: string; items: ResultItem[]; offset: number }) => {
if (items.length === 0) return null;
return (
<div>
<div className="mono" style={{ padding: '10px 16px 4px', fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--text-2)' }}>
{title}
</div>
{items.map((it, i) => {
const idx = offset + i;
const active = idx === sel;
return (
<button
key={`${it.kind}-${it.sport}-${it.label}`}
type="button"
onClick={() => go(it)}
onMouseEnter={() => setSel(idx)}
className="mono"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
width: '100%',
textAlign: 'left',
padding: '9px 16px',
background: active ? 'color-mix(in srgb, var(--g-a) 12%, transparent)' : 'transparent',
border: 'none',
borderLeft: `2px solid ${active ? 'var(--g-a)' : 'transparent'}`,
cursor: 'pointer',
color: 'var(--text-0, #E8E8F0)',
fontSize: 13,
}}
>
{/* ROW-GRAMMAR: never truncate a name — wrap instead. */}
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
{it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>}
<span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}>
{it.sport.toUpperCase()}
</span>
</span>
</button>
);
})}
</div>
);
};
return (
<div
className="fade-in"
role="dialog"
aria-modal="true"
aria-label="Search players and teams"
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
zIndex: 90,
background: 'rgba(6,6,11,.72)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
padding: '12vh 16px 16px',
}}
>
<div
onClick={(e) => e.stopPropagation()}
className="scanlines"
style={{
width: '100%',
maxWidth: 560,
background: 'var(--bg-1)',
border: '1px solid var(--border-hi)',
borderRadius: 12,
overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<span className="mono" aria-hidden style={{ color: 'var(--g-a)', fontSize: 13 }}></span>
<input
ref={inputRef}
value={q}
onChange={(e) => 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',
}}
/>
<kbd className="mono" style={{ fontSize: 9.5, color: 'var(--text-2)', border: '1px solid var(--border)', borderRadius: 4, padding: '2px 5px' }}>
ESC
</kbd>
</div>
<div style={{ maxHeight: '52vh', overflowY: 'auto', paddingBottom: 6 }}>
{q.trim().length < 2 ? (
<p className="mono" style={{ padding: '18px 16px', fontSize: 11.5, color: 'var(--text-2)' }}>
Type two characters. to move, Enter to open.
</p>
) : flat.length === 0 ? (
<p className="mono" style={{ padding: '18px 16px', fontSize: 11.5, color: 'var(--text-2)' }}>
No matches on the wire.
</p>
) : (
<>
<Group title="PLAYERS" items={players} offset={0} />
<Group title="TEAMS" items={teams} offset={players.length} />
</>
)}
</div>
</div>
</div>
);
}
+6 -2
View File
@@ -2,6 +2,10 @@ type SparklineProps = {
data: number[]; data: number[];
/** Direction tint: green when up, red when down. */ /** Direction tint: green when up, red when down. */
up?: boolean; 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; w?: number;
h?: number; h?: number;
}; };
@@ -10,7 +14,7 @@ type SparklineProps = {
* Tiny SVG line for line movement (§5). Green up / red down, dot on the last * 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. * 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 safe = data && data.length ? data : [0, 0];
const min = Math.min(...safe); const min = Math.min(...safe);
const max = Math.max(...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; return [x, y] as const;
}); });
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' '); 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]; const last = pts[pts.length - 1];
return ( return (
<svg width={w} height={h} style={{ display: 'block', overflow: 'visible' }} aria-hidden> <svg width={w} height={h} style={{ display: 'block', overflow: 'visible' }} aria-hidden>
+69 -9
View File
@@ -1,5 +1,6 @@
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import GradeBadge from '@/components/vyndr/GradeBadge'; import GradeBadge from '@/components/vyndr/GradeBadge';
import Sparkline from '@/components/vyndr/Sparkline';
import { nextRunLabelET } from '@/lib/pipelineSchedule'; import { nextRunLabelET } from '@/lib/pipelineSchedule';
// A1 S3 — BOOK IT is a real per-book deep link now (organic until the // 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. // 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 // Session 64 (A1-S5) — NOT-IN-LINEUP kills the grade display (struck
// through, actions suppressed). The locked ledger read is untouched. // through, actions suppressed). The locked ledger read is untouched.
dead?: boolean; 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 (
<span
className="mono"
title={`Last ${dots.length} vs tonight's line — ${hits} of ${dots.length} cleared (newest first)`}
aria-label={`Last ${dots.length} games versus tonight's line: ${hits} cleared`}
style={{ fontSize: 10, letterSpacing: '0.12em', whiteSpace: 'nowrap' }}
>
{dots.map((hit, i) => (
<span key={i} style={{ color: hit ? 'var(--g-a, #00D4A0)' : 'var(--text-2, #4A4A5E)' }}>
{hit ? '●' : '○'}
</span>
))}
</span>
);
}
/** 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 (
<span title={`Line movement since lock — ${lines[0]}${lines[lines.length - 1]} (${lines.length} captures)`} style={{ display: 'inline-flex', alignItems: 'center' }}>
<Sparkline data={lines} color={color} w={64} h={16} />
</span>
);
} }
/** Session 64 (A1-S5) — lineup + injury viability chips (real feeds only). */ /** Session 64 (A1-S5) — lineup + injury viability chips (real feeds only). */
@@ -270,8 +317,6 @@ export default function StatStrip({
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName> <PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span> <span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{/* Session 64 (A1-S5) — lineup confirmation + injury wire chips. */}
<ViabilityChips lineup={lineup} injury={injury} />
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />} {archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
{archetype?.secondary && ( {archetype?.secondary && (
<> <>
@@ -279,6 +324,9 @@ export default function StatStrip({
<ArchetypeBadge archetype={archetype.secondary} size="sm" variant="ghost" /> <ArchetypeBadge archetype={archetype.secondary} size="sm" variant="ghost" />
</> </>
)} )}
{/* ROW-GRAMMAR §2 — identity is one contiguous run (name → team →
archetype); viability status chips FOLLOW it. Session 64 chips. */}
<ViabilityChips lineup={lineup} injury={injury} />
</div> </div>
<div className="mono game-lines-grid" style={{ fontSize: 12, color: 'var(--text-0)', letterSpacing: '0.02em' }}> <div className="mono game-lines-grid" style={{ fontSize: 12, color: 'var(--text-0)', letterSpacing: '0.02em' }}>
{stats.map((s, i) => ( {stats.map((s, i) => (
@@ -327,10 +375,14 @@ export default function StatStrip({
const toward = p.delta?.direction === 'toward'; const toward = p.delta?.direction === 'toward';
return ( return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8' }}> <div className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#B8BCC8', flexWrap: 'wrap' }}>
<span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span> <span style={{ color: '#fff' }}>{p.stat} {p.side}{p.line}</span>
{/* ROW-GRAMMAR slot 4 — market context: best-price dot,
then movement chip, BEFORE any model output. */}
<BestPriceDot p={p} /> <BestPriceDot p={p} />
{/* Phase 2.5 — a revised grade is PUBLIC: original struck through. */} {!p.dead && <MovementChip p={p} />}
{/* ROW-GRAMMAR slot 5 — model output. Phase 2.5: a revised
grade is PUBLIC — original struck through, then current. */}
{p.revisedFrom && ( {p.revisedFrom && (
<span className="mono" title="Grade revised after the line moved against the read — original preserved" style={{ fontSize: 10.5, color: 'var(--text-2)', textDecoration: 'line-through' }}>{p.revisedFrom}</span> <span className="mono" title="Grade revised after the line moved against the read — original preserved" style={{ fontSize: 10.5, color: 'var(--text-2)', textDecoration: 'line-through' }}>{p.revisedFrom}</span>
)} )}
@@ -346,7 +398,6 @@ export default function StatStrip({
<GradeBadge grade={p.grade} size="sm" /> <GradeBadge grade={p.grade} size="sm" />
) )
)} )}
{!p.dead && <MovementChip p={p} />}
<OutcomeChip p={p} /> <OutcomeChip p={p} />
{!p.outcome && !p.dead && <ParlayBtn p={p} />} {!p.outcome && !p.dead && <ParlayBtn p={p} />}
{!p.outcome && !p.dead && <BookItTeaser p={p} />} {!p.outcome && !p.dead && <BookItTeaser p={p} />}
@@ -356,11 +407,20 @@ export default function StatStrip({
</span> </span>
)} )}
</div> </div>
{p.delta && ( {/* ROW-GRAMMAR sub-line — stat + market context, fixed order:
<div className="mono" style={{ fontSize: 11, color: toward ? 'var(--g-a)' : 'var(--amber)' }}> last-10 dots · line sparkline · current-line delta. Each
Current {p.delta.currentLine} · {p.delta.delta > 0 ? '▲' : '▼'} {toward ? 'TOWARD' : 'AWAY'} {p.delta.delta > 0 ? '+' : ''}{p.delta.delta} renders only on real data; all absent → no sub-line. */}
{(p.last10Dots?.length || (p.history && p.history.length >= 3) || p.delta) ? (
<div className="mono" style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<DotStrip dots={p.last10Dots} />
<LineSparkline p={p} />
{p.delta && (
<span style={{ color: toward ? 'var(--g-a)' : 'var(--amber)' }}>
Current {p.delta.currentLine} · {p.delta.delta > 0 ? '▲' : '▼'} {toward ? 'TOWARD' : 'AWAY'} {p.delta.delta > 0 ? '+' : ''}{p.delta.delta}
</span>
)}
</div> </div>
)} ) : null}
</div> </div>
); );
})} })}
+4
View File
@@ -25,6 +25,10 @@ export { default as StatStrip } from './StatStrip';
export type { StatCell, StripProp, StripArchetype } from './StatStrip'; export type { StatCell, StripProp, StripArchetype } from './StatStrip';
export { default as BookChip } from './BookChip'; 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 { export {
GRADE_COLORS, GRADE_COLORS,
GRADE_HEX, GRADE_HEX,
+4
View File
@@ -384,6 +384,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
// + the ORIGINAL grade when a public revision happened. // + the ORIGINAL grade when a public revision happened.
movement: rec.movement || null, movement: rec.movement || null,
revisedFrom: rec.revised_from_grade || 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 { } else {
byPlayer[pk].props.push({ byPlayer[pk].props.push({
+119
View File
@@ -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 };