Deploy: never-dark offseason hub (Wave 2) — ESPN news wire + futures board
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -184,6 +184,10 @@ const streaksRoutes = require('./routes/streaks');
|
||||
app.use('/api/streaks', streaksRoutes);
|
||||
const hotListRoutes = require('./routes/hotlist');
|
||||
app.use('/api/hotlist', hotListRoutes);
|
||||
// Wave 2A — offseason/never-dark hub feeds. FREE ESPN news wire + quota-
|
||||
// disciplined championship futures (both graceful/empty; never error).
|
||||
app.use('/api/news', require('./routes/news'));
|
||||
app.use('/api/futures', require('./routes/futures'));
|
||||
// Session 28 — parlay builder, line-movement views, book comparison.
|
||||
// All three are zero-credit: parlay math is pure, lines read a Redis
|
||||
// snapshot history, books read the cached odds props.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* /api/futures/:sport (Wave 2A, D1) — championship/outright futures.
|
||||
*
|
||||
* Response contract:
|
||||
* { sport, updated_at, markets: [{ key, title, selections:
|
||||
* [{ name, price, prevPrice?, move? }] }] }
|
||||
* No data / gated-off (FUTURES_ENABLED=0) / quota-skip → { sport, updated_at,
|
||||
* markets: [] }. Never errors, never fabricates a price.
|
||||
*
|
||||
* QUOTA-DISCIPLINED: futuresService fetches at most one odds-api outrights call
|
||||
* per sport per 12h (default), isolated from the daily player-prop budget.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Public throttle (30/min — futures are cheap to serve from cache).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 30 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
|
||||
|
||||
router.get('/:sport', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
try {
|
||||
const { getFutures } = require('../services/futuresService');
|
||||
const payload = await getFutures(sport);
|
||||
res.set('Cache-Control', 'public, max-age=600');
|
||||
return res.set(MISSION_HEADER).json(payload);
|
||||
} catch (err) {
|
||||
console.error('[futures]', err.message);
|
||||
return res.set(MISSION_HEADER).json({ sport, updated_at: new Date().toISOString(), markets: [] });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* /api/news/:sport (Wave 2A) — the FREE ESPN news wire. NO odds-api credits.
|
||||
*
|
||||
* Response contract:
|
||||
* { sport, items: [{ id, headline, description, published (ISO), type,
|
||||
* athlete?: { name, key }, team?: string, href }] }
|
||||
* Newest first, cap ~25. Empty feed / unknown sport → { sport, items: [] }.
|
||||
* Never errors.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Public throttle (60/min; ESPN is free but be respectful).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
|
||||
|
||||
router.get('/:sport', async (req, res) => {
|
||||
const sport = String(req.params.sport || '').toLowerCase();
|
||||
try {
|
||||
const { getNews } = require('../services/newsService');
|
||||
const payload = await getNews(sport);
|
||||
res.set('Cache-Control', 'public, max-age=600');
|
||||
return res.set(MISSION_HEADER).json(payload);
|
||||
} catch (err) {
|
||||
console.error('[news]', err.message);
|
||||
return res.set(MISSION_HEADER).json({ sport, items: [] });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,228 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* futuresService — championship/outright futures wire (Wave 2A, D1).
|
||||
*
|
||||
* D1 DECISION: BUILD futures, QUOTA-DISCIPLINED. Futures spend the already-paid
|
||||
* The Odds API 500/mo quota, so this path is deliberately isolated from the
|
||||
* daily player-prop budget:
|
||||
* - Separate `FUTURES_KEYS` map in oddsService (NOT in SPORT_KEYS / the
|
||||
* snapshot loop).
|
||||
* - A SINGLE `/sports/{key}/odds?markets=outrights` call per refresh —
|
||||
* outrights do NOT fan out over events, so it's 1 credit, not 1+N.
|
||||
* - Long logical TTL (default 12h; futures move slowly). With a 12h TTL the
|
||||
* ceiling is 2 refreshes/day/sport ≈ 60 credits/mo/sport IF continuously
|
||||
* queried; realistically a handful/day because fetches are on-demand
|
||||
* (cache-aside, no cron). `FUTURES_ENABLED=0` skips the fetch entirely.
|
||||
*
|
||||
* Contract (consumed by the parallel Wave 2B ExploreHub):
|
||||
* { sport, updated_at, markets: [{ key, title, selections:
|
||||
* [{ name, price (american int), prevPrice?: int, move?:
|
||||
* 'shortening'|'drifting'|'flat' }] }] }
|
||||
* No data / gated-off / quota-skip → { sport, updated_at, markets: [] }.
|
||||
* NEVER errors, NEVER fabricates a price.
|
||||
*
|
||||
* Line-movement REUSE: `move` is computed by diffing the current outright PRICE
|
||||
* against the previous cached snapshot's price per selection — the same SHAPE
|
||||
* as snapshotService.computeLineDeltas/signedDelta, but on `odds` (price) not
|
||||
* `line`. Prev prices are persisted INSIDE the `futures:{sport}` cache value
|
||||
* (no new Redis key): the previous write's `price` fields ARE the next diff's
|
||||
* reference.
|
||||
*/
|
||||
|
||||
const { normalizeOutrights, americanToDecimal } = require('../utils/oddsNormalizer');
|
||||
const { FUTURES_KEYS, ODDS_API_BASE } = require('./oddsService');
|
||||
|
||||
const DEFAULT_TTL = 12 * 60 * 60; // 12h logical freshness (quota-disciplined).
|
||||
const PERSIST_TTL = 7 * 24 * 60 * 60; // 7d Redis persistence so prev survives the gap.
|
||||
const HTTP_TIMEOUT_MS = 12_000;
|
||||
// A price is "flat" unless the decimal payout moves by at least this much —
|
||||
// filters odds-jitter from a real steam/drift (mirrors DELTA_NOISE in shape).
|
||||
const MOVE_EPSILON = 0.05;
|
||||
|
||||
function futuresEnabled() {
|
||||
const raw = process.env.FUTURES_ENABLED;
|
||||
if (raw === '0' || String(raw).toLowerCase() === 'false') return false;
|
||||
return true; // default ON
|
||||
}
|
||||
|
||||
function configuredTTL() {
|
||||
const raw = process.env.FUTURES_CACHE_TTL_SECONDS;
|
||||
if (!raw) return DEFAULT_TTL;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 300 || n > 86400) return DEFAULT_TTL; // 5m..24h
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: classify a price move from prev→cur american prices. Uses DECIMAL
|
||||
* payout so the sign is correct across the +/- american boundary.
|
||||
* shortening = odds got SHORTER (payout ↓, market more confident)
|
||||
* drifting = odds got LONGER (payout ↑, market less confident)
|
||||
* flat = |Δdecimal| < epsilon, or either price unusable
|
||||
*/
|
||||
function priceMove(prevPrice, curPrice) {
|
||||
const prev = americanToDecimal(prevPrice);
|
||||
const cur = americanToDecimal(curPrice);
|
||||
if (prev == null || cur == null) return 'flat';
|
||||
const d = cur - prev;
|
||||
if (d <= -MOVE_EPSILON) return 'shortening';
|
||||
if (d >= MOVE_EPSILON) return 'drifting';
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff freshly-normalized markets against the previous cached contract markets,
|
||||
* attaching `prevPrice` + `move` per selection. Pure. `prevMarkets` may be
|
||||
* undefined (first-ever fetch → every selection is `flat` with no prevPrice).
|
||||
*/
|
||||
function attachMoves(newMarkets, prevMarkets) {
|
||||
const prevIndex = {}; // key|name → price
|
||||
for (const m of Array.isArray(prevMarkets) ? prevMarkets : []) {
|
||||
for (const s of m.selections || []) {
|
||||
if (s && s.name != null && s.price != null) prevIndex[`${m.key}|${s.name}`] = s.price;
|
||||
}
|
||||
}
|
||||
return (newMarkets || []).map((m) => ({
|
||||
key: m.key,
|
||||
title: m.title,
|
||||
selections: (m.selections || []).map((s) => {
|
||||
const prevPrice = prevIndex[`${m.key}|${s.name}`];
|
||||
const sel = { name: s.name, price: s.price };
|
||||
if (prevPrice != null) {
|
||||
sel.prevPrice = prevPrice;
|
||||
sel.move = priceMove(prevPrice, s.price);
|
||||
} else {
|
||||
sel.move = 'flat';
|
||||
}
|
||||
return sel;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure NEWS→MOVE causal tie (optional-but-nice). Match a futures move to a
|
||||
* headline whose `published` PRECEDES the move within `windowHours`. Returns
|
||||
* `[{ moveKey, headline }]`. `moves` = [{ key, name, at }] (at = ISO time the
|
||||
* move was detected). NEVER invents a cause — a move with no preceding headline
|
||||
* in-window yields nothing.
|
||||
*/
|
||||
function linkNewsToMoves(newsItems, moves, windowHours = 48) {
|
||||
const out = [];
|
||||
const windowMs = windowHours * 60 * 60 * 1000;
|
||||
const dated = (Array.isArray(newsItems) ? newsItems : [])
|
||||
.filter((n) => n && n.published && Number.isFinite(Date.parse(n.published)))
|
||||
.map((n) => ({ headline: n.headline, t: Date.parse(n.published) }));
|
||||
for (const mv of Array.isArray(moves) ? moves : []) {
|
||||
const at = mv && mv.at ? Date.parse(mv.at) : NaN;
|
||||
if (!Number.isFinite(at)) continue;
|
||||
let best = null;
|
||||
for (const n of dated) {
|
||||
if (n.t <= at && at - n.t <= windowMs) {
|
||||
if (!best || n.t > best.t) best = n; // most recent preceding headline
|
||||
}
|
||||
}
|
||||
if (best) out.push({ moveKey: `${mv.key}|${mv.name}`, headline: best.headline });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build the empty-but-valid contract response. */
|
||||
function emptyResponse(sport) {
|
||||
return { sport, updated_at: new Date().toISOString(), markets: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* getFutures(sport, deps) → the Wave-2 futures contract. Cache-aside on a long
|
||||
* logical TTL; persists prev prices inside the same key for the next diff.
|
||||
* All deps injectable → unit-tested with zero network. Never throws.
|
||||
*/
|
||||
async function getFutures(sport, deps = {}) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
const futuresKey = (deps.FUTURES_KEYS || FUTURES_KEYS)[sp];
|
||||
if (!futuresKey) return emptyResponse(sp);
|
||||
|
||||
const enabled = deps.enabled != null ? deps.enabled : futuresEnabled();
|
||||
const ttl = deps.ttl != null ? deps.ttl : configuredTTL();
|
||||
const cacheGet = deps.cacheGet || require('../utils/redis').cacheGet;
|
||||
const cacheSet = deps.cacheSet || require('../utils/redis').cacheSet;
|
||||
const key = `futures:${sp}`;
|
||||
|
||||
// Read the last board (persists past the logical TTL so prev prices survive).
|
||||
let cached = null;
|
||||
try { cached = await cacheGet(key); } catch (_) { cached = null; }
|
||||
const now = Date.now();
|
||||
const isFresh = cached && cached.updated_at &&
|
||||
(now - Date.parse(cached.updated_at)) < ttl * 1000;
|
||||
|
||||
// Gate: disabled → never spend quota. Serve the last board if we have one
|
||||
// (honest, already-captured market values), else empty.
|
||||
if (!enabled) {
|
||||
if (cached && Array.isArray(cached.markets)) {
|
||||
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
||||
}
|
||||
return emptyResponse(sp);
|
||||
}
|
||||
|
||||
// Fresh cache → serve it, no fetch.
|
||||
if (isFresh) {
|
||||
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
||||
}
|
||||
|
||||
// Stale / cold → fetch a single outrights call.
|
||||
const apiKey = deps.apiKey || process.env.ODDS_API_KEY;
|
||||
if (!apiKey) {
|
||||
if (cached && Array.isArray(cached.markets)) {
|
||||
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
||||
}
|
||||
return emptyResponse(sp);
|
||||
}
|
||||
|
||||
const axios = deps.axios || require('axios');
|
||||
const base = deps.ODDS_API_BASE || ODDS_API_BASE;
|
||||
try {
|
||||
const res = await axios.get(`${base}/${futuresKey}/odds`, {
|
||||
params: { apiKey, regions: 'us', markets: 'outrights', oddsFormat: 'american' },
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
});
|
||||
// Best-effort quota sync (same headers the player-prop path reads).
|
||||
try {
|
||||
if (res && res.headers) {
|
||||
require('./quotaTracker').syncFromHeaders('odds-api', res.headers);
|
||||
}
|
||||
} catch (_) { /* quota tracking is a signal, never a dependency */ }
|
||||
|
||||
const normalized = normalizeOutrights(res.data);
|
||||
const markets = attachMoves(normalized, cached && cached.markets);
|
||||
|
||||
// Empty board (off-season / no priced selections) → serve empty, but keep
|
||||
// any prior good board rather than clobbering it with nothing.
|
||||
if (markets.length === 0) {
|
||||
if (cached && Array.isArray(cached.markets) && cached.markets.length > 0) {
|
||||
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
||||
}
|
||||
return emptyResponse(sp);
|
||||
}
|
||||
|
||||
const updated_at = new Date().toISOString();
|
||||
try { await cacheSet(key, { updated_at, markets }, PERSIST_TTL); } catch (_) { /* best-effort */ }
|
||||
return { sport: sp, updated_at, markets };
|
||||
} catch (e) {
|
||||
console.warn(`[futures] ${sp} fetch failed:`, e.message);
|
||||
// Serve stale board if present — better than empty, never fabricated.
|
||||
if (cached && Array.isArray(cached.markets)) {
|
||||
return { sport: sp, updated_at: cached.updated_at, markets: cached.markets };
|
||||
}
|
||||
return emptyResponse(sp);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getFutures,
|
||||
priceMove,
|
||||
attachMoves,
|
||||
linkNewsToMoves,
|
||||
futuresEnabled,
|
||||
configuredTTL,
|
||||
__internals: { DEFAULT_TTL, PERSIST_TTL, MOVE_EPSILON },
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* newsService — the FREE ESPN news wire (Wave 2A / offseason-hub).
|
||||
*
|
||||
* Mirrors injuryService's pattern EXACTLY: per-sport FEEDS map → ESPN's
|
||||
* free `/news` endpoint, injectable axios/http + cache 15 min, pure
|
||||
* `parseNews(json)` → the Wave-2 news contract. NO odds-api credits.
|
||||
*
|
||||
* Contract (consumed by the parallel Wave 2B ExploreHub):
|
||||
* { sport, items: [{ id, headline, description, published (ISO), type,
|
||||
* athlete?: { name, key }, team?: string, href }] }
|
||||
* Newest first, cap ~25. Empty feed → { sport, items: [] } (never errors).
|
||||
*
|
||||
* Governing rules: never fabricate — athlete/team are extracted ONLY from
|
||||
* ESPN `categories[]` where present, and are ABSENT (undefined) otherwise.
|
||||
*/
|
||||
|
||||
const { nameKey } = require('../utils/playerName');
|
||||
|
||||
const TTL = 900; // 15 min — same cadence as injuryService.
|
||||
const HTTP_TIMEOUT_MS = 10_000;
|
||||
const CAP = 25;
|
||||
|
||||
// ESPN site API `/news` per sport (FREE, no auth). Soccer/nfl/nhl are
|
||||
// optional — absent keys degrade to an empty feed, never an error.
|
||||
const FEEDS = {
|
||||
mlb: 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/news',
|
||||
nba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/news',
|
||||
wnba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/news',
|
||||
nfl: 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/news',
|
||||
nhl: 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/news',
|
||||
};
|
||||
|
||||
/** First `categories[]` entry of a given ESPN type, or null. */
|
||||
function firstCategory(categories, type) {
|
||||
for (const c of Array.isArray(categories) ? categories : []) {
|
||||
if (c && c.type === type) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: one ESPN article → the contract's item, or null if unusable
|
||||
* (no headline). athlete/team are attached ONLY when a matching category
|
||||
* is present — never invented.
|
||||
*/
|
||||
function parseArticle(article) {
|
||||
if (!article || !article.headline) return null;
|
||||
|
||||
const cats = article.categories;
|
||||
const item = {
|
||||
id: article.id != null ? String(article.id) : null,
|
||||
headline: String(article.headline),
|
||||
description: article.description ? String(article.description) : '',
|
||||
published: article.published || article.lastModified || null,
|
||||
type: article.type || 'Story',
|
||||
href: (article.links && article.links.web && article.links.web.href) || null,
|
||||
};
|
||||
|
||||
// Athlete — from the first `type:'athlete'` category (has `description` = name).
|
||||
const ath = firstCategory(cats, 'athlete');
|
||||
const athName = ath && (ath.description || (ath.athlete && ath.athlete.description));
|
||||
if (athName) {
|
||||
item.athlete = { name: String(athName), key: nameKey(String(athName)) };
|
||||
}
|
||||
|
||||
// Team — from the first `type:'team'` category (full display name, or abbr).
|
||||
const tm = firstCategory(cats, 'team');
|
||||
const tmName = tm && (tm.description || (tm.team && (tm.team.description || tm.team.abbreviation)));
|
||||
if (tmName) {
|
||||
item.team = String(tmName);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Pure: ESPN news JSON → the contract's items[] (newest first, capped). */
|
||||
function parseNews(json) {
|
||||
const articles = (json && Array.isArray(json.articles)) ? json.articles : [];
|
||||
const items = [];
|
||||
for (const a of articles) {
|
||||
const it = parseArticle(a);
|
||||
if (it) items.push(it);
|
||||
}
|
||||
// ESPN returns newest-first already; sort defensively by `published` desc
|
||||
// (items with no timestamp sink to the bottom, order preserved otherwise).
|
||||
items.sort((x, y) => {
|
||||
const tx = x.published ? Date.parse(x.published) : 0;
|
||||
const ty = y.published ? Date.parse(y.published) : 0;
|
||||
return (ty || 0) - (tx || 0);
|
||||
});
|
||||
return items.slice(0, CAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch (cache-aside) the news wire for a sport → { sport, items: [] }.
|
||||
* Injectable axios/cacheGet/cacheSet → unit-tested fully offline. Never
|
||||
* throws: an unknown sport or a failed fetch returns an empty feed.
|
||||
*/
|
||||
async function getNews(sport, opts = {}) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
const url = FEEDS[sp];
|
||||
if (!url) return { sport: sp, items: [] };
|
||||
|
||||
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
|
||||
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
|
||||
const key = `news:${sp}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheGet(key);
|
||||
if (cached && Array.isArray(cached.items)) return { sport: sp, items: cached.items };
|
||||
} catch (_) { /* cache miss / redis down → fetch live */ }
|
||||
|
||||
const axios = opts.axios || require('axios');
|
||||
try {
|
||||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
|
||||
const items = parseNews(res.data);
|
||||
try { await cacheSet(key, { items }, TTL); } catch (_) { /* best-effort */ }
|
||||
return { sport: sp, items };
|
||||
} catch (e) {
|
||||
console.warn(`[news] ${sp} fetch failed:`, e.message);
|
||||
return { sport: sp, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getNews, parseNews, parseArticle, __internals: { TTL, FEEDS, CAP } };
|
||||
@@ -76,6 +76,18 @@ const SPORT_KEYS = {
|
||||
const SOCCER_SPORT_KEYS = Object.freeze(
|
||||
Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_'))
|
||||
);
|
||||
|
||||
// Wave 2A — FUTURES / OUTRIGHTS keys. DELIBERATELY SEPARATE from SPORT_KEYS so
|
||||
// they NEVER enter the daily player-prop `getOdds`/snapshot loop or its budget.
|
||||
// These are championship-winner outright markets on the already-paid odds-api
|
||||
// key, fetched by `futuresService` on a long TTL (quota-disciplined). Each key
|
||||
// is a SINGLE `/sports/{key}/odds?markets=outrights` call (outrights don't fan
|
||||
// out over events → 1 credit per refresh, not 1+N like player props).
|
||||
const FUTURES_KEYS = Object.freeze({
|
||||
mlb: 'baseball_mlb_world_series_winner',
|
||||
nba: 'basketball_nba_championship_winner',
|
||||
wnba: 'basketball_wnba_championship_winner',
|
||||
});
|
||||
// Session 16 — per-sport market lists.
|
||||
//
|
||||
// The old `ALL_MARKETS = every key in MARKET_MAP` would send
|
||||
@@ -510,6 +522,9 @@ module.exports = {
|
||||
getCacheKey,
|
||||
SPORT_KEYS,
|
||||
SOCCER_SPORT_KEYS,
|
||||
// Wave 2A — futures/outrights keys (separate budget; futuresService only).
|
||||
FUTURES_KEYS,
|
||||
ODDS_API_BASE,
|
||||
// Wave 6 — combat game-level markets (moneyline + round total).
|
||||
MMA_MARKETS,
|
||||
// Session 16 — per-sport market scoping.
|
||||
|
||||
@@ -157,6 +157,69 @@ function normalizeProps(eventsWithOdds) {
|
||||
return props;
|
||||
}
|
||||
|
||||
/**
|
||||
* american → decimal payout (for "best price" selection across books).
|
||||
* +150 → 2.5, -200 → 1.5. Returns null for a non-integer/absent price.
|
||||
*/
|
||||
function americanToDecimal(price) {
|
||||
if (price == null || typeof price !== 'number' || !Number.isFinite(price) || price === 0) return null;
|
||||
return price > 0 ? 1 + price / 100 : 1 + 100 / Math.abs(price);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wave 2A — outrights/futures normalizer. NEW branch: outrights outcomes are
|
||||
* `{ name, price }` with NO `point` and NO Over/Under grouping, so
|
||||
* `normalizeProps` (which requires `outcome.point` + pairs Over/Under) would
|
||||
* DROP every one of them. This keeps them.
|
||||
*
|
||||
* Input: odds-api `/sports/{key}/odds?markets=outrights` events. Each event is
|
||||
* a single futures market (e.g. "MLB World Series Winner"). Output: one market
|
||||
* per event with the BEST available american price per selection across the
|
||||
* ALLOWED books (best = highest decimal payout — honest "best price you can
|
||||
* get"), plus the book that posted it for provenance.
|
||||
*
|
||||
* [{ key: sport_key, title: sport_title, selections:
|
||||
* [{ name, price (american int), book }] }]
|
||||
*
|
||||
* Never fabricates: an event with no outrights market / no priced outcomes
|
||||
* yields no selections; a book not on the ALLOWED list is skipped.
|
||||
*/
|
||||
function normalizeOutrights(events) {
|
||||
const markets = [];
|
||||
for (const event of Array.isArray(events) ? events : []) {
|
||||
const key = event.sport_key || event.id;
|
||||
if (!key) continue;
|
||||
const title = event.sport_title || key;
|
||||
|
||||
// best[name] = { price, book, dec }
|
||||
const best = {};
|
||||
for (const bookmaker of Array.isArray(event.bookmakers) ? event.bookmakers : []) {
|
||||
if (!ALLOWED_BOOKS.has(bookmaker.key)) continue;
|
||||
for (const market of Array.isArray(bookmaker.markets) ? bookmaker.markets : []) {
|
||||
if (market.key !== 'outrights') continue;
|
||||
for (const outcome of market.outcomes || []) {
|
||||
if (!outcome || !outcome.name || outcome.price == null) continue;
|
||||
const dec = americanToDecimal(outcome.price);
|
||||
if (dec == null) continue;
|
||||
const cur = best[outcome.name];
|
||||
if (!cur || dec > cur.dec) {
|
||||
best[outcome.name] = { price: outcome.price, book: bookmaker.key, dec };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selections = Object.keys(best).map((name) => ({
|
||||
name,
|
||||
price: best[name].price,
|
||||
book: best[name].book,
|
||||
}));
|
||||
if (selections.length === 0) continue;
|
||||
markets.push({ key: String(key), title: String(title), selections });
|
||||
}
|
||||
return markets;
|
||||
}
|
||||
|
||||
function extractSpreads(eventsWithOdds) {
|
||||
const spreads = [];
|
||||
|
||||
@@ -197,4 +260,4 @@ function extractSpreads(eventsWithOdds) {
|
||||
return spreads;
|
||||
}
|
||||
|
||||
module.exports = { normalizeProps, extractSpreads, MARKET_MAP, ALLOWED_BOOKS };
|
||||
module.exports = { normalizeProps, normalizeOutrights, americanToDecimal, extractSpreads, MARKET_MAP, ALLOWED_BOOKS };
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Wave 2B — OFFSEASON HUB · FuturesBoard. Source + helper asserts. Futures are
|
||||
// TRACKED, NOT GRADED — the board carries that label, never renders a grade,
|
||||
// self-hides on an empty feed, and colors movement per the contract
|
||||
// (shortening→green / drifting→amber / flat→dim) — NEVER red for a drift.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const src = fs.readFileSync(path.join(WEB, 'components', 'vyndr', 'FuturesBoard.tsx'), 'utf8');
|
||||
const { moveColor, moveLabel, MOVE_COLOR } = require('../../web/src/lib/futuresMove');
|
||||
|
||||
describe('futuresMove — the color contract (never red)', () => {
|
||||
it('shortening → green (toward the selection)', () => {
|
||||
expect(moveColor('shortening')).toBe('var(--g-a)');
|
||||
});
|
||||
it('drifting → amber (caution, NOT a miss)', () => {
|
||||
expect(moveColor('drifting')).toBe('var(--amber)');
|
||||
});
|
||||
it('flat / absent / unknown → dim (say less)', () => {
|
||||
expect(moveColor('flat')).toBe('var(--text-2)');
|
||||
expect(moveColor(undefined)).toBe('var(--text-2)');
|
||||
expect(moveColor('sideways')).toBe('var(--text-2)');
|
||||
});
|
||||
it('NO move ever renders red (--miss reserved for settled-negative)', () => {
|
||||
Object.values(MOVE_COLOR).forEach((v) => expect(v).not.toMatch(/miss/));
|
||||
['shortening', 'drifting', 'flat', undefined, 'x'].forEach((m) =>
|
||||
expect(moveColor(m)).not.toMatch(/miss/)
|
||||
);
|
||||
});
|
||||
it('moveLabel is empty when there is no move (never fabricated)', () => {
|
||||
expect(moveLabel(undefined)).toBe('');
|
||||
expect(moveLabel('shortening')).toMatch(/SHORTENING/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FuturesBoard.tsx source', () => {
|
||||
it('fetches the REAL futures endpoint', () => {
|
||||
expect(src).toMatch(/\/api\/futures\//);
|
||||
});
|
||||
|
||||
it('carries the honest "TRACKED · NOT GRADED" label', () => {
|
||||
expect(src).toContain('TRACKED · NOT GRADED');
|
||||
});
|
||||
|
||||
it('NEVER renders a grade on a future (no GradeBadge)', () => {
|
||||
expect(src).not.toContain('GradeBadge');
|
||||
expect(src).not.toMatch(/import.*GradeBadge/);
|
||||
});
|
||||
|
||||
it('SELF-HIDES when there are no markets (returns null)', () => {
|
||||
expect(src).toMatch(/return null/);
|
||||
expect(src).toMatch(/list\.length === 0/);
|
||||
});
|
||||
|
||||
it('renders real markets + selections with mono tabular prices', () => {
|
||||
expect(src).toMatch(/list\.map/);
|
||||
expect(src).toMatch(/selections/);
|
||||
expect(src).toContain('className="mono"');
|
||||
expect(src).toContain('tabular-nums');
|
||||
});
|
||||
|
||||
it('shows a move ONLY when the backend supplied one, via the contract color', () => {
|
||||
expect(src).toContain('moveColor');
|
||||
expect(src).toContain('moveLabel');
|
||||
// movement is gated on a truthy label (never fabricated)
|
||||
expect(src).toMatch(/label &&/);
|
||||
});
|
||||
|
||||
it('data surface carries NO glitch classes', () => {
|
||||
expect(src).not.toMatch(/wm-tear|glitch-shift|head-tear|glitch-hover/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExploreHub mounts FuturesBoard', () => {
|
||||
const hub = fs.readFileSync(path.join(WEB, 'components', 'ExploreHub.tsx'), 'utf8');
|
||||
it('imports + mounts <FuturesBoard sport=', () => {
|
||||
expect(hub).toMatch(/import FuturesBoard from '@\/components\/vyndr\/FuturesBoard'/);
|
||||
expect(hub).toMatch(/<FuturesBoard sport=\{sport\}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
getFutures, priceMove, attachMoves, linkNewsToMoves,
|
||||
} = require('../../src/services/futuresService');
|
||||
|
||||
const outrightEvents = [
|
||||
{
|
||||
id: 'evt-ws',
|
||||
sport_key: 'baseball_mlb_world_series_winner',
|
||||
sport_title: 'MLB World Series Winner',
|
||||
bookmakers: [
|
||||
{
|
||||
key: 'draftkings',
|
||||
markets: [{ key: 'outrights', outcomes: [
|
||||
{ name: 'Los Angeles Dodgers', price: 300 },
|
||||
{ name: 'New York Yankees', price: 500 },
|
||||
] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('priceMove (pure)', () => {
|
||||
test('shortening when payout drops (+400 → +300)', () => {
|
||||
expect(priceMove(400, 300)).toBe('shortening');
|
||||
});
|
||||
test('drifting when payout rises (+300 → +450)', () => {
|
||||
expect(priceMove(300, 450)).toBe('drifting');
|
||||
});
|
||||
test('flat within epsilon / unusable prices', () => {
|
||||
expect(priceMove(300, 300)).toBe('flat');
|
||||
expect(priceMove(null, 300)).toBe('flat');
|
||||
expect(priceMove(300, null)).toBe('flat');
|
||||
});
|
||||
test('sign correct across the +/- american boundary', () => {
|
||||
// +120 (dec 2.2) → -110 (dec 1.909): payout dropped → shortening
|
||||
expect(priceMove(120, -110)).toBe('shortening');
|
||||
// -110 → +120: payout rose → drifting
|
||||
expect(priceMove(-110, 120)).toBe('drifting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachMoves (pure)', () => {
|
||||
const cur = [{ key: 'k', title: 'K', selections: [
|
||||
{ name: 'A', price: 300 }, { name: 'B', price: 500 },
|
||||
] }];
|
||||
|
||||
test('first fetch (no prev) → move:flat, no prevPrice', () => {
|
||||
const out = attachMoves(cur, undefined);
|
||||
expect(out[0].selections[0]).toEqual({ name: 'A', price: 300, move: 'flat' });
|
||||
});
|
||||
|
||||
test('diffs against prev cached markets → prevPrice + move', () => {
|
||||
const prev = [{ key: 'k', selections: [{ name: 'A', price: 400 }, { name: 'B', price: 500 }] }];
|
||||
const out = attachMoves(cur, prev);
|
||||
const a = out[0].selections.find((s) => s.name === 'A');
|
||||
expect(a.prevPrice).toBe(400);
|
||||
expect(a.move).toBe('shortening'); // 400 → 300
|
||||
const b = out[0].selections.find((s) => s.name === 'B');
|
||||
expect(b.prevPrice).toBe(500);
|
||||
expect(b.move).toBe('flat'); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkNewsToMoves (pure causal tie)', () => {
|
||||
const news = [
|
||||
{ headline: 'Dodgers acquire ace at deadline', published: '2026-07-30T12:00:00Z' },
|
||||
{ headline: 'Unrelated older story', published: '2026-06-01T12:00:00Z' },
|
||||
];
|
||||
test('matches a move to the most-recent PRECEDING headline in window', () => {
|
||||
const moves = [{ key: 'k', name: 'Los Angeles Dodgers', at: '2026-07-31T00:00:00Z' }];
|
||||
const links = linkNewsToMoves(news, moves, 48);
|
||||
expect(links).toEqual([{ moveKey: 'k|Los Angeles Dodgers', headline: 'Dodgers acquire ace at deadline' }]);
|
||||
});
|
||||
test('no headline within window → no cause (never invents)', () => {
|
||||
const moves = [{ key: 'k', name: 'X', at: '2026-08-15T00:00:00Z' }];
|
||||
expect(linkNewsToMoves(news, moves, 48)).toEqual([]);
|
||||
});
|
||||
test('headline AFTER the move is not a cause', () => {
|
||||
const moves = [{ key: 'k', name: 'X', at: '2026-07-29T00:00:00Z' }];
|
||||
expect(linkNewsToMoves(news, moves, 48)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFutures (injectable, offline)', () => {
|
||||
const baseDeps = { apiKey: 'test', cacheGet: async () => null, cacheSet: async () => {} };
|
||||
|
||||
test('unknown sport → { markets: [] }, no fetch', async () => {
|
||||
const axios = { get: jest.fn() };
|
||||
const out = await getFutures('cricket', { ...baseDeps, axios });
|
||||
expect(out.markets).toEqual([]);
|
||||
expect(out.sport).toBe('cricket');
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('quota gate OFF (enabled:false) → { markets: [] }, no fetch', async () => {
|
||||
const axios = { get: jest.fn() };
|
||||
const out = await getFutures('mlb', { ...baseDeps, axios, enabled: false });
|
||||
expect(out.markets).toEqual([]);
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cold cache + enabled → fetches once, normalizes markets', async () => {
|
||||
const axios = { get: jest.fn().mockResolvedValue({ data: outrightEvents, headers: {} }) };
|
||||
const cacheSet = jest.fn(async () => {});
|
||||
const out = await getFutures('mlb', { ...baseDeps, axios, enabled: true, ttl: 3600, cacheSet });
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
// single outrights call
|
||||
expect(axios.get.mock.calls[0][1].params.markets).toBe('outrights');
|
||||
expect(out.markets).toHaveLength(1);
|
||||
expect(out.markets[0].key).toBe('baseball_mlb_world_series_winner');
|
||||
const dodgers = out.markets[0].selections.find((s) => s.name === 'Los Angeles Dodgers');
|
||||
expect(dodgers.price).toBe(300);
|
||||
expect(dodgers.move).toBe('flat'); // no prev
|
||||
expect(cacheSet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('price move shortening from a prev-price cache', async () => {
|
||||
const prevCache = {
|
||||
updated_at: '2000-01-01T00:00:00Z', // ancient → stale → refetch
|
||||
markets: [{ key: 'baseball_mlb_world_series_winner', selections: [
|
||||
{ name: 'Los Angeles Dodgers', price: 450 }, // was +450, now +300 → shortening
|
||||
] }],
|
||||
};
|
||||
const axios = { get: jest.fn().mockResolvedValue({ data: outrightEvents, headers: {} }) };
|
||||
const out = await getFutures('mlb', {
|
||||
apiKey: 'test', axios, enabled: true, ttl: 3600,
|
||||
cacheGet: async () => prevCache, cacheSet: async () => {},
|
||||
});
|
||||
const dodgers = out.markets[0].selections.find((s) => s.name === 'Los Angeles Dodgers');
|
||||
expect(dodgers.prevPrice).toBe(450);
|
||||
expect(dodgers.move).toBe('shortening');
|
||||
});
|
||||
|
||||
test('fresh cache → served without fetching', async () => {
|
||||
const fresh = {
|
||||
updated_at: new Date().toISOString(),
|
||||
markets: [{ key: 'baseball_mlb_world_series_winner', title: 'WS', selections: [{ name: 'A', price: 100 }] }],
|
||||
};
|
||||
const axios = { get: jest.fn() };
|
||||
const out = await getFutures('mlb', {
|
||||
apiKey: 'test', axios, enabled: true, ttl: 3600,
|
||||
cacheGet: async () => fresh, cacheSet: async () => {},
|
||||
});
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
expect(out.markets).toEqual(fresh.markets);
|
||||
});
|
||||
|
||||
test('fetch failure → empty (never throws, never fabricates)', async () => {
|
||||
const axios = { get: jest.fn().mockRejectedValue(new Error('boom')) };
|
||||
const out = await getFutures('mlb', { ...baseDeps, axios, enabled: true, ttl: 3600 });
|
||||
expect(out.markets).toEqual([]);
|
||||
});
|
||||
|
||||
test('empty board with no prior cache → { markets: [] }', async () => {
|
||||
const axios = { get: jest.fn().mockResolvedValue({ data: [], headers: {} }) };
|
||||
const out = await getFutures('mlb', { ...baseDeps, axios, enabled: true, ttl: 3600 });
|
||||
expect(out.markets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
'use strict';
|
||||
|
||||
const { parseNews, getNews } = require('../../src/services/newsService');
|
||||
|
||||
const espnFixture = {
|
||||
header: 'MLB News',
|
||||
articles: [
|
||||
{
|
||||
id: 45735348,
|
||||
type: 'Story',
|
||||
headline: 'Aaron Judge homers twice in Yankees win',
|
||||
description: 'The captain went deep in the seventh and ninth.',
|
||||
published: '2026-07-14T03:08:19Z',
|
||||
links: { web: { href: 'https://www.espn.com/mlb/story/_/id/45735348/judge' } },
|
||||
categories: [
|
||||
{ type: 'league', description: 'MLB', sportId: 10 },
|
||||
{ type: 'athlete', description: 'Aaron Judge', athleteId: 33192, athlete: { id: 33192, description: 'Aaron Judge' } },
|
||||
{ type: 'team', description: 'New York Yankees', teamId: 10, team: { id: 10, description: 'New York Yankees', abbreviation: 'NYY' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 45735111,
|
||||
type: 'HeadlineNews',
|
||||
headline: 'Commissioner addresses expansion timeline',
|
||||
description: 'League office weighs in on the next two cities.',
|
||||
published: '2026-07-13T20:00:00Z',
|
||||
links: { web: { href: 'https://www.espn.com/mlb/story/_/id/45735111/expansion' } },
|
||||
categories: [
|
||||
{ type: 'league', description: 'MLB', sportId: 10 }, // NO athlete, NO team
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('parseNews (pure)', () => {
|
||||
test('maps ESPN articles → the contract items[]', () => {
|
||||
const items = parseNews(espnFixture);
|
||||
expect(items).toHaveLength(2);
|
||||
const a = items[0];
|
||||
expect(a.id).toBe('45735348');
|
||||
expect(a.headline).toMatch(/Aaron Judge/);
|
||||
expect(a.type).toBe('Story');
|
||||
expect(a.published).toBe('2026-07-14T03:08:19Z');
|
||||
expect(a.href).toBe('https://www.espn.com/mlb/story/_/id/45735348/judge');
|
||||
});
|
||||
|
||||
test('extracts athlete + team from categories where present', () => {
|
||||
const [a] = parseNews(espnFixture);
|
||||
expect(a.athlete).toEqual({ name: 'Aaron Judge', key: expect.any(String) });
|
||||
expect(a.athlete.key).toBe('aaron judge');
|
||||
expect(a.team).toBe('New York Yankees');
|
||||
});
|
||||
|
||||
test('missing athlete/team → fields ABSENT, never invented', () => {
|
||||
const items = parseNews(espnFixture);
|
||||
const noEntity = items.find((i) => i.headline.includes('expansion'));
|
||||
expect(noEntity).toBeTruthy();
|
||||
expect('athlete' in noEntity).toBe(false);
|
||||
expect('team' in noEntity).toBe(false);
|
||||
});
|
||||
|
||||
test('sorts newest first by published', () => {
|
||||
const items = parseNews(espnFixture);
|
||||
expect(items[0].published > items[1].published).toBe(true);
|
||||
});
|
||||
|
||||
test('empty / malformed input → []', () => {
|
||||
expect(parseNews({})).toEqual([]);
|
||||
expect(parseNews(null)).toEqual([]);
|
||||
expect(parseNews({ articles: 'nope' })).toEqual([]);
|
||||
// Article with no headline is dropped.
|
||||
expect(parseNews({ articles: [{ id: 1, description: 'x' }] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNews (injectable, offline)', () => {
|
||||
test('unknown sport → { sport, items: [] } without fetching', async () => {
|
||||
const axios = { get: jest.fn() };
|
||||
const out = await getNews('cricket', { axios, cacheGet: async () => null, cacheSet: async () => {} });
|
||||
expect(out).toEqual({ sport: 'cricket', items: [] });
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cache miss → fetches, parses, caches', async () => {
|
||||
const axios = { get: jest.fn().mockResolvedValue({ data: espnFixture }) };
|
||||
const cacheSet = jest.fn(async () => {});
|
||||
const out = await getNews('mlb', { axios, cacheGet: async () => null, cacheSet });
|
||||
expect(out.sport).toBe('mlb');
|
||||
expect(out.items).toHaveLength(2);
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
expect(cacheSet).toHaveBeenCalledWith('news:mlb', { items: expect.any(Array) }, expect.any(Number));
|
||||
});
|
||||
|
||||
test('cache hit → serves cache, no fetch', async () => {
|
||||
const axios = { get: jest.fn() };
|
||||
const cached = { items: [{ id: '1', headline: 'cached', published: '2026-07-14T00:00:00Z', type: 'Story', href: null, description: '' }] };
|
||||
const out = await getNews('mlb', { axios, cacheGet: async () => cached, cacheSet: async () => {} });
|
||||
expect(out.items).toEqual(cached.items);
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fetch failure → empty feed (never throws)', async () => {
|
||||
const axios = { get: jest.fn().mockRejectedValue(new Error('network')) };
|
||||
const out = await getNews('nba', { axios, cacheGet: async () => null, cacheSet: async () => {} });
|
||||
expect(out).toEqual({ sport: 'nba', items: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Wave 2B — OFFSEASON HUB · NewsWire. Source + helper asserts. The wire is REAL
|
||||
// ESPN news + real injuries fed into the retired INJURY_WIRE layout; it self-
|
||||
// hides when both feeds are empty, timestamps are mono, and no sample data
|
||||
// from TerminalTemplates is imported.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const src = fs.readFileSync(path.join(WEB, 'components', 'vyndr', 'NewsWire.tsx'), 'utf8');
|
||||
const { timeAgo, typeLabel } = require('../../web/src/lib/newsFormat');
|
||||
|
||||
describe('newsFormat.timeAgo — mono-timestamp source', () => {
|
||||
const now = Date.parse('2026-07-13T12:00:00Z');
|
||||
it('absent / invalid → empty string (never a fabricated stamp)', () => {
|
||||
expect(timeAgo(null, now)).toBe('');
|
||||
expect(timeAgo('', now)).toBe('');
|
||||
expect(timeAgo('not-a-date', now)).toBe('');
|
||||
});
|
||||
it('minutes / hours / days ago', () => {
|
||||
expect(timeAgo(now - 5 * 60000, now)).toBe('5m ago');
|
||||
expect(timeAgo(now - 3 * 3600000, now)).toBe('3h ago');
|
||||
expect(timeAgo(now - 2 * 86400000, now)).toBe('2d ago');
|
||||
});
|
||||
it('sub-minute → now', () => {
|
||||
expect(timeAgo(now - 10000, now)).toBe('now');
|
||||
});
|
||||
});
|
||||
|
||||
describe('newsFormat.typeLabel', () => {
|
||||
it('maps known ESPN types', () => {
|
||||
expect(typeLabel('Recap')).toBe('RECAP');
|
||||
expect(typeLabel('HeadlineNews')).toBe('NEWS');
|
||||
});
|
||||
it('falls back to a title-cased split for unknown types (never dropped)', () => {
|
||||
expect(typeLabel('Story')).toBe('STORY');
|
||||
expect(typeLabel('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NewsWire.tsx source', () => {
|
||||
it('fetches the REAL news + injuries endpoints (no sample data)', () => {
|
||||
expect(src).toMatch(/\/api\/news\//);
|
||||
expect(src).toMatch(/\/api\/schedule\/.*\/injuries/);
|
||||
expect(src).not.toMatch(/import[^\n]*TerminalTemplates/); // never routes sample data
|
||||
expect(src).not.toContain('Jamal Murray'); // no sample constants
|
||||
});
|
||||
|
||||
it('SELF-HIDES when both feeds are empty (returns null)', () => {
|
||||
expect(src).toMatch(/return null/);
|
||||
// the guard combines news + injuries emptiness
|
||||
expect(src).toMatch(/news\.length === 0 && inj\.length === 0/);
|
||||
});
|
||||
|
||||
it('renders real items newest-first and maps them', () => {
|
||||
expect(src).toMatch(/news\.map/);
|
||||
expect(src).toMatch(/\.sort\(/);
|
||||
expect(src).toContain('headline');
|
||||
});
|
||||
|
||||
it('timestamps + statuses render in mono (data-is-mono rule)', () => {
|
||||
expect(src).toContain('className="mono"');
|
||||
expect(src).toContain('timeAgo(');
|
||||
});
|
||||
|
||||
it('links player/team via the canonical helpers', () => {
|
||||
expect(src).toContain('playerHref');
|
||||
expect(src).toMatch(/\/team\//);
|
||||
});
|
||||
|
||||
it('data surface carries NO glitch classes', () => {
|
||||
expect(src).not.toMatch(/wm-tear|glitch-shift|head-tear|glitch-hover/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExploreHub mounts NewsWire', () => {
|
||||
const hub = fs.readFileSync(path.join(WEB, 'components', 'ExploreHub.tsx'), 'utf8');
|
||||
it('imports + mounts <NewsWire sport=', () => {
|
||||
expect(hub).toMatch(/import NewsWire from '@\/components\/vyndr\/NewsWire'/);
|
||||
expect(hub).toMatch(/<NewsWire sport=\{sport\}/);
|
||||
});
|
||||
it('is offseason-aware (leads with the hub when the board is dark)', () => {
|
||||
expect(hub).toContain('OFF_SEASON');
|
||||
expect(hub).toMatch(/isOffseason/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
const { normalizeOutrights, normalizeProps, americanToDecimal } = require('../../src/utils/oddsNormalizer');
|
||||
|
||||
const outrightEvents = [
|
||||
{
|
||||
id: 'evt-ws',
|
||||
sport_key: 'baseball_mlb_world_series_winner',
|
||||
sport_title: 'MLB World Series Winner',
|
||||
commence_time: '2026-10-20T00:00:00Z',
|
||||
bookmakers: [
|
||||
{
|
||||
key: 'draftkings',
|
||||
title: 'DraftKings',
|
||||
markets: [
|
||||
{
|
||||
key: 'outrights',
|
||||
outcomes: [
|
||||
{ name: 'Los Angeles Dodgers', price: 350 },
|
||||
{ name: 'New York Yankees', price: 450 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'fanduel',
|
||||
title: 'FanDuel',
|
||||
markets: [
|
||||
{
|
||||
key: 'outrights',
|
||||
outcomes: [
|
||||
{ name: 'Los Angeles Dodgers', price: 400 }, // better payout than DK's 350
|
||||
{ name: 'New York Yankees', price: 420 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'bovada', // NOT allowed → skipped
|
||||
markets: [{ key: 'outrights', outcomes: [{ name: 'Los Angeles Dodgers', price: 9999 }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('normalizeOutrights', () => {
|
||||
test('normalizes outright outcomes (name+price, no point) into markets', () => {
|
||||
const markets = normalizeOutrights(outrightEvents);
|
||||
expect(markets).toHaveLength(1);
|
||||
const m = markets[0];
|
||||
expect(m.key).toBe('baseball_mlb_world_series_winner');
|
||||
expect(m.title).toBe('MLB World Series Winner');
|
||||
expect(m.selections).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('picks the BEST (highest decimal payout) price per selection across allowed books', () => {
|
||||
const [m] = normalizeOutrights(outrightEvents);
|
||||
const dodgers = m.selections.find((s) => s.name === 'Los Angeles Dodgers');
|
||||
// FanDuel +400 beats DraftKings +350; Bovada is not an allowed book.
|
||||
expect(dodgers.price).toBe(400);
|
||||
expect(dodgers.book).toBe('fanduel');
|
||||
const yankees = m.selections.find((s) => s.name === 'New York Yankees');
|
||||
// DraftKings +450 beats FanDuel +420.
|
||||
expect(yankees.price).toBe(450);
|
||||
expect(yankees.book).toBe('draftkings');
|
||||
});
|
||||
|
||||
test('normalizeProps would DROP these outright outcomes (no point) — the reason a new branch exists', () => {
|
||||
// Feed the SAME events through the player-prop normalizer: outrights have
|
||||
// no `point` and no Over/Under, so normalizeProps yields nothing.
|
||||
const props = normalizeProps(outrightEvents);
|
||||
expect(props).toEqual([]);
|
||||
// ...while normalizeOutrights keeps them.
|
||||
expect(normalizeOutrights(outrightEvents)[0].selections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('never fabricates: empty / malformed input → empty array', () => {
|
||||
expect(normalizeOutrights([])).toEqual([]);
|
||||
expect(normalizeOutrights(null)).toEqual([]);
|
||||
expect(normalizeOutrights([{ sport_key: 'x', bookmakers: [] }])).toEqual([]);
|
||||
expect(normalizeOutrights([{ sport_key: 'x', bookmakers: [{ key: 'draftkings', markets: [{ key: 'outrights', outcomes: [{ name: 'A', price: null }] }] }] }])).toEqual([]);
|
||||
});
|
||||
|
||||
test('americanToDecimal: +150→2.5, -200→1.5, junk→null', () => {
|
||||
expect(americanToDecimal(150)).toBeCloseTo(2.5, 5);
|
||||
expect(americanToDecimal(-200)).toBeCloseTo(1.5, 5);
|
||||
expect(americanToDecimal(0)).toBeNull();
|
||||
expect(americanToDecimal(null)).toBeNull();
|
||||
expect(americanToDecimal('150')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Futures proxy (Wave 2A, S25 rule). Forwards to the Express
|
||||
* `/api/futures/:sport` (quota-disciplined championship outrights).
|
||||
* Degrades to an empty-but-valid board so the hub never blows up.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
|
||||
const { sport } = await params;
|
||||
const sportLc = String(sport || '').toLowerCase();
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/futures/${encodeURIComponent(sportLc)}${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ sport: sportLc, updated_at: new Date().toISOString(), markets: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* News wire proxy (Wave 2A, S25 rule). Forwards to the Express
|
||||
* `/api/news/:sport` (FREE ESPN feed). Degrades to an empty-but-valid
|
||||
* feed so the offseason hub never blows up.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) {
|
||||
const { sport } = await params;
|
||||
const sportLc = String(sport || '').toLowerCase();
|
||||
const qs = req.nextUrl.search;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/news/${encodeURIComponent(sportLc)}${qs}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ sport: sportLc, items: [] });
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,11 @@ import { playerHref } from '@/lib/playerHref';
|
||||
*/
|
||||
import StreaksPanel from '@/components/StreaksPanel';
|
||||
import HotListPanel from '@/components/HotListPanel';
|
||||
import NewsWire from '@/components/vyndr/NewsWire';
|
||||
import FuturesBoard from '@/components/vyndr/FuturesBoard';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||
import { OFF_SEASON } from '@/lib/emptyState';
|
||||
|
||||
interface Leader {
|
||||
player: string;
|
||||
@@ -54,6 +57,21 @@ export default function ExploreHub() {
|
||||
return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
|
||||
}, [leaders, query]);
|
||||
|
||||
// Wave 2B — never-dark hub. When the selected sport is in its OFF-SEASON, the
|
||||
// hub LEADS with futures + the wire (real, always-available data) instead of
|
||||
// a dark leaderboard; in-season those sections COMPLEMENT the live board
|
||||
// below. Each self-hides independently on an empty feed — no empty boxes.
|
||||
const off = OFF_SEASON[sport as keyof typeof OFF_SEASON];
|
||||
const isOffseason = !!(off && off.months.includes(new Date().getMonth()));
|
||||
|
||||
// Both sections self-hide (return null) when their feeds are empty.
|
||||
const hubSections = (
|
||||
<>
|
||||
<FuturesBoard sport={sport} />
|
||||
<NewsWire sport={sport} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 920, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 14, marginBottom: 22, flexWrap: 'wrap' }}>
|
||||
@@ -78,6 +96,9 @@ export default function ExploreHub() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OFF-SEASON LEAD — futures + wire come FIRST when the board is dark. */}
|
||||
{isOffseason && hubSections}
|
||||
|
||||
{/* FILTER BAR */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap', marginBottom: 14, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '11px 14px' }}>
|
||||
<span className="mono" style={{ fontSize: 14, color: 'var(--text-2)' }}>⌕</span>
|
||||
@@ -135,6 +156,9 @@ export default function ExploreHub() {
|
||||
<StreaksPanel sport={sport} tier={tier} stat="all" />
|
||||
<HotListPanel sport={sport} tier={tier} stat="all" />
|
||||
</div>
|
||||
|
||||
{/* IN-SEASON — futures + wire COMPLEMENT the live board below it. */}
|
||||
{!isOffseason && hubSections}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import { moveColor, moveLabel } from '@/lib/futuresMove';
|
||||
|
||||
/* ============================================================
|
||||
FuturesBoard (Wave 2B — Offseason Hub). The never-dark FUTURES board: REAL
|
||||
championship / win-total / award markets from `/api/futures/:sport`.
|
||||
|
||||
HONESTY (Wave 3 line): futures are TRACKED, NOT GRADED — season-long
|
||||
grading is a different model that does not exist yet. So this board SHOWS
|
||||
prices + movement and carries an explicit "TRACKED · NOT GRADED" label. It
|
||||
NEVER renders a grade on a future, and a price MOVE is shown ONLY when the
|
||||
backend supplies one (never fabricated). Movement colors follow the
|
||||
contract (shortening→green / drifting→amber / flat→dim) and NEVER red.
|
||||
|
||||
SELF-HIDES when there are no markets (markets:[]). Prices are mono +
|
||||
tabular (data-is-mono brand rule).
|
||||
============================================================ */
|
||||
|
||||
interface Selection {
|
||||
name: string;
|
||||
price: number | string;
|
||||
prevPrice?: number | string;
|
||||
move?: 'shortening' | 'drifting' | 'flat';
|
||||
}
|
||||
interface FuturesMarket {
|
||||
key: string;
|
||||
title: string;
|
||||
selections: Selection[];
|
||||
}
|
||||
|
||||
export interface FuturesBoardProps {
|
||||
sport: string;
|
||||
}
|
||||
|
||||
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
|
||||
|
||||
// American-odds display: a signed integer keeps its sign; anything else prints
|
||||
// as-is (absent beats a fabricated/mis-converted number).
|
||||
function fmtPrice(price: number | string): string {
|
||||
if (price == null || price === '') return '—';
|
||||
if (typeof price === 'number' && Number.isFinite(price)) {
|
||||
return price > 0 ? `+${price}` : String(price);
|
||||
}
|
||||
const s = String(price);
|
||||
return /^-?\d+$/.test(s) && Number(s) > 0 ? `+${s}` : s;
|
||||
}
|
||||
|
||||
export default function FuturesBoard({ sport }: FuturesBoardProps) {
|
||||
const [markets, setMarkets] = useState<FuturesMarket[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setMarkets(null);
|
||||
fetch(`/api/futures/${encodeURIComponent(sport)}`)
|
||||
.then((r) => (r.ok ? r.json() : { markets: [] }))
|
||||
.then((d) => { if (!cancelled) setMarkets(Array.isArray(d?.markets) ? d.markets : []); })
|
||||
.catch(() => { if (!cancelled) setMarkets([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [sport]);
|
||||
|
||||
const list = markets || [];
|
||||
// SELF-HIDE: no markets (empty feed or still loading).
|
||||
if (list.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<SectionHead>FUTURES BOARD</SectionHead>
|
||||
<SportBadge sport={sport} size="sm" />
|
||||
{/* The honesty line — futures are shown/tracked, not model-graded. */}
|
||||
<span className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.08em', padding: '2px 7px', borderRadius: 3, color: 'var(--text-2)', border: '1px solid var(--border)' }}>
|
||||
TRACKED · NOT GRADED
|
||||
</span>
|
||||
</div>
|
||||
<p className="mono" style={{ margin: '0 0 14px', fontSize: 11, color: 'var(--text-2)' }}>
|
||||
Live book prices, tracked over time. VYNDR does not grade futures — season-long grading is a separate model.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12 }}>
|
||||
{list.map((m) => (
|
||||
<div key={m.key} style={card}>
|
||||
<div className="label" style={{ marginBottom: 11 }}>{m.title}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||||
{(Array.isArray(m.selections) ? m.selections : []).map((sel, i) => {
|
||||
const label = moveLabel(sel.move);
|
||||
return (
|
||||
<div key={`${sel.name}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{sel.name}</span>
|
||||
{/* Movement — ONLY when the backend supplied a `move`. */}
|
||||
{label && (
|
||||
<span className="mono" style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.05em', color: moveColor(sel.move) }}>{label}</span>
|
||||
)}
|
||||
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)', fontVariantNumeric: 'tabular-nums', minWidth: 54, textAlign: 'right' }}>{fmtPrice(sel.price)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import { timeAgo, typeLabel } from '@/lib/newsFormat';
|
||||
|
||||
/* ============================================================
|
||||
NewsWire (Wave 2B — Offseason Hub). The never-dark LEAGUE WIRE: REAL ESPN
|
||||
headlines (`/api/news/:sport`) + REAL injuries (`/api/schedule/:sport/
|
||||
injuries`), fed into the retired TerminalTemplates INJURY_WIRE layout.
|
||||
|
||||
HONESTY: everything real or absent. No sample data ever reaches here. The
|
||||
whole component SELF-HIDES when both feeds are empty (news items:[] AND no
|
||||
injuries) — the offseason hub shows what is genuinely available or nothing.
|
||||
Timestamps + statuses are mono (the data-is-mono brand rule).
|
||||
============================================================ */
|
||||
|
||||
interface Athlete { name: string; key?: string }
|
||||
interface NewsItem {
|
||||
id: string | number;
|
||||
headline: string;
|
||||
description?: string;
|
||||
published?: string | number;
|
||||
type?: string;
|
||||
athlete?: Athlete | null;
|
||||
team?: string | null;
|
||||
href?: string | null;
|
||||
}
|
||||
|
||||
export interface NewsWireProps {
|
||||
sport: string;
|
||||
}
|
||||
|
||||
// Injury status → color token. OUT is a genuine availability negative (red);
|
||||
// GTD/questionable is caution (amber); probable/other reads neutral. Tokens
|
||||
// only — no raw hex (QA parity rule).
|
||||
function statusColor(s: string): string {
|
||||
const v = String(s || '').toUpperCase();
|
||||
if (v === 'OUT') return 'var(--miss)';
|
||||
if (v === 'GTD' || v === 'QUESTIONABLE' || v === 'DTD') return 'var(--amber)';
|
||||
return 'var(--text-1)';
|
||||
}
|
||||
|
||||
// nameKey ("aaron judge") → display ("Aaron Judge"). Real data, just cased.
|
||||
function titleCase(key: string): string {
|
||||
return String(key || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const card: React.CSSProperties = { background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16 };
|
||||
|
||||
export default function NewsWire({ sport }: NewsWireProps) {
|
||||
const [items, setItems] = useState<NewsItem[] | null>(null);
|
||||
const [injuries, setInjuries] = useState<Array<[string, string]> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setItems(null);
|
||||
setInjuries(null);
|
||||
// News feed (Wave 2A contract). Self-hide on any failure — never invent.
|
||||
fetch(`/api/news/${encodeURIComponent(sport)}`)
|
||||
.then((r) => (r.ok ? r.json() : { items: [] }))
|
||||
.then((d) => { if (!cancelled) setItems(Array.isArray(d?.items) ? d.items : []); })
|
||||
.catch(() => { if (!cancelled) setItems([]); });
|
||||
// Injury wire (already-live contract: { byPlayer: { nameKey -> STATUS } }).
|
||||
fetch(`/api/schedule/${encodeURIComponent(sport)}/injuries`)
|
||||
.then((r) => (r.ok ? r.json() : { byPlayer: {} }))
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
const bp = d && typeof d.byPlayer === 'object' && d.byPlayer ? d.byPlayer : {};
|
||||
setInjuries(Object.entries(bp).map(([k, v]) => [k, String(v)] as [string, string]));
|
||||
})
|
||||
.catch(() => { if (!cancelled) setInjuries([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [sport]);
|
||||
|
||||
// Newest-first (defensive — the backend already sorts, but never trust order).
|
||||
const news = useMemo(() => {
|
||||
const list = Array.isArray(items) ? [...items] : [];
|
||||
return list.sort((a, b) => {
|
||||
const ta = a.published ? Date.parse(String(a.published)) : 0;
|
||||
const tb = b.published ? Date.parse(String(b.published)) : 0;
|
||||
return (Number.isFinite(tb) ? tb : 0) - (Number.isFinite(ta) ? ta : 0);
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
const inj = injuries || [];
|
||||
|
||||
// SELF-HIDE: both feeds empty (still loading = null on both → also hidden).
|
||||
if (news.length === 0 && inj.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 28 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<SectionHead>LEAGUE WIRE · NEWS + INJURIES</SectionHead>
|
||||
<SportBadge sport={sport} size="sm" />
|
||||
</div>
|
||||
|
||||
{/* INJURY WIRE — real chips (self-hides its own block when empty). */}
|
||||
{inj.length > 0 && (
|
||||
<div style={{ ...card, marginBottom: 12 }}>
|
||||
<div className="label" style={{ marginBottom: 10 }}>INJURY REPORT · {inj.length} FLAGGED</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{inj.map(([key, status]) => (
|
||||
<a
|
||||
key={key}
|
||||
href={playerHref(titleCase(key), sport)}
|
||||
className="mono"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, textDecoration: 'none', fontSize: 11.5, padding: '4px 9px', borderRadius: 5, border: '1px solid var(--border)', background: 'var(--bg-2, #0d0d14)', color: 'var(--text-1)' }}
|
||||
>
|
||||
<span style={{ fontWeight: 600, color: '#fff', fontFamily: 'var(--sans)' }}>{titleCase(key)}</span>
|
||||
<span style={{ fontWeight: 700, letterSpacing: '0.06em', color: statusColor(status) }}>{String(status).toUpperCase()}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NEWS HEADLINES — real ESPN feed, newest first (self-hides when empty). */}
|
||||
{news.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{news.map((n) => {
|
||||
const stamp = timeAgo(n.published);
|
||||
const chip = typeLabel(n.type);
|
||||
const HeadlineTag = n.href ? 'a' : 'div';
|
||||
return (
|
||||
<div key={n.id} style={card}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 7, flexWrap: 'wrap' }}>
|
||||
{chip && <span className="mono" style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.08em', padding: '2px 6px', borderRadius: 3, color: 'var(--g-a)', border: '1px solid rgba(0,212,160,.3)' }}>{chip}</span>}
|
||||
{stamp && <span className="mono" style={{ fontSize: 10.5, color: 'var(--text-2)' }}>{stamp}</span>}
|
||||
</div>
|
||||
<HeadlineTag
|
||||
{...(n.href ? { href: n.href, target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
style={{ display: 'block', fontSize: 14, fontWeight: 700, lineHeight: 1.4, color: '#fff', textDecoration: 'none' }}
|
||||
>
|
||||
{n.headline}
|
||||
</HeadlineTag>
|
||||
{n.description && (
|
||||
<p style={{ margin: '6px 0 0', fontSize: 12.5, lineHeight: 1.55, color: 'var(--text-1)' }}>{n.description}</p>
|
||||
)}
|
||||
{(n.athlete?.name || n.team) && (
|
||||
<div className="mono" style={{ marginTop: 9, display: 'flex', alignItems: 'center', gap: 10, fontSize: 11 }}>
|
||||
{n.athlete?.name && (
|
||||
<a href={playerHref(n.athlete.name, sport)} style={{ color: 'var(--g-a)', textDecoration: 'none', fontWeight: 700 }}>↳ {n.athlete.name}</a>
|
||||
)}
|
||||
{n.team && (
|
||||
<a href={`/team/${encodeURIComponent(n.team)}?sport=${encodeURIComponent(sport)}`} style={{ color: 'var(--text-1)', textDecoration: 'none' }}>{n.team}</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,12 @@ export { default as Sparkline } from './Sparkline';
|
||||
export { default as Ticker } from './Ticker';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as MarketBreadth } from './MarketBreadth';
|
||||
|
||||
/* Wave 2B — Offseason / never-dark hub */
|
||||
export { default as NewsWire } from './NewsWire';
|
||||
export type { NewsWireProps } from './NewsWire';
|
||||
export { default as FuturesBoard } from './FuturesBoard';
|
||||
export type { FuturesBoardProps } from './FuturesBoard';
|
||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||
export { default as GradeResultCard } from './GradeResultCard';
|
||||
export type { GradeResultData } from './GradeResultCard';
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* ============================================================
|
||||
VYNDR — FUTURES MOVEMENT color/label (Wave 2B, Offseason Hub).
|
||||
|
||||
The color contract for a futures selection's price movement. HONESTY RULE:
|
||||
futures are TRACKED, not model-graded — a movement is only ever shown when
|
||||
the backend actually supplies a `move` (never fabricated). The color space
|
||||
is deliberately narrow and NEVER red:
|
||||
• shortening → the market moved TOWARD the selection → green (value/steam)
|
||||
• drifting → the market moved AWAY → amber (caution)
|
||||
• flat / absent → no movement to report → dim (say less)
|
||||
Red is reserved system-wide for settled-negative outcomes; a futures drift
|
||||
is NOT a miss, so it must never render red.
|
||||
|
||||
CommonJS so the .tsx surface imports it (allowJs) AND Jest exercises it.
|
||||
============================================================ */
|
||||
|
||||
// Canonical move → CSS custom-property token. No branch returns the red
|
||||
// (--miss) token — a normal drift is caution (amber), never a miss.
|
||||
const MOVE_COLOR = {
|
||||
shortening: 'var(--g-a)', // green — toward the selection (value)
|
||||
drifting: 'var(--amber)', // amber — away from the selection (caution)
|
||||
flat: 'var(--text-2)', // dim — no movement
|
||||
};
|
||||
|
||||
/** moveColor(move) — the token for a move, defaulting to dim for flat/absent. */
|
||||
function moveColor(move) {
|
||||
const m = String(move || '').toLowerCase();
|
||||
return MOVE_COLOR[m] || MOVE_COLOR.flat;
|
||||
}
|
||||
|
||||
/** moveLabel(move) — a short mono glyph+word, or '' when there is no move. */
|
||||
function moveLabel(move) {
|
||||
const m = String(move || '').toLowerCase();
|
||||
if (m === 'shortening') return '▼ SHORTENING';
|
||||
if (m === 'drifting') return '▲ DRIFTING';
|
||||
if (m === 'flat') return '— FLAT';
|
||||
return '';
|
||||
}
|
||||
|
||||
module.exports = { MOVE_COLOR, moveColor, moveLabel };
|
||||
@@ -0,0 +1,53 @@
|
||||
/* ============================================================
|
||||
VYNDR — NEWS WIRE formatting (Wave 2B, Offseason Hub).
|
||||
|
||||
Small pure helpers for the real-ESPN news wire. Timestamps are rendered in
|
||||
mono (the brand rule: all data is mono); an unparseable/absent published
|
||||
time yields '' (absent beats a fabricated "just now"). Type chips map the
|
||||
raw ESPN feed type to a short human label without inventing categories.
|
||||
|
||||
CommonJS so the .tsx surface imports it (allowJs) AND Jest exercises it.
|
||||
============================================================ */
|
||||
|
||||
/**
|
||||
* timeAgo(published, now) — compact relative time for a mono timestamp.
|
||||
* Invalid / missing input → '' (never a fabricated stamp). `published` is an
|
||||
* ISO string or epoch ms; `now` defaults to Date.now().
|
||||
*/
|
||||
function timeAgo(published, now = Date.now()) {
|
||||
if (published == null || published === '') return '';
|
||||
const t = typeof published === 'number' ? published : Date.parse(String(published));
|
||||
if (!Number.isFinite(t)) return '';
|
||||
const diff = Number(now) - t;
|
||||
if (!Number.isFinite(diff)) return '';
|
||||
if (diff < 0) return 'now';
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return 'now';
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
// Raw ESPN feed types → short display labels. Unknown types fall back to a
|
||||
// title-cased version of the raw string (never dropped, never invented).
|
||||
const TYPE_LABEL = {
|
||||
Recap: 'RECAP',
|
||||
HeadlineNews: 'NEWS',
|
||||
Story: 'STORY',
|
||||
Preview: 'PREVIEW',
|
||||
Notebook: 'NOTEBOOK',
|
||||
Media: 'MEDIA',
|
||||
};
|
||||
|
||||
/** typeLabel(type) — short uppercase chip label, or '' when absent. */
|
||||
function typeLabel(type) {
|
||||
if (!type) return '';
|
||||
const raw = String(type);
|
||||
if (TYPE_LABEL[raw]) return TYPE_LABEL[raw];
|
||||
// split camelCase / snake and uppercase
|
||||
return raw.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim().toUpperCase();
|
||||
}
|
||||
|
||||
module.exports = { timeAgo, typeLabel, TYPE_LABEL };
|
||||
Reference in New Issue
Block a user