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:
@@ -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: [] });
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user