Wave 2B: Offseason never-dark hub — NewsWire + FuturesBoard on /explore

Extend /explore (ExploreHub) into the 365-day never-dark hub. Two new
self-hiding sections feed REAL always-available data into the offseason:

- NewsWire (components/vyndr/NewsWire.tsx): real ESPN headlines from
  /api/news/:sport (newest-first, mono timestamps, type chips, player/team
  links) + real injuries from /api/schedule/:sport/injuries (OUT/GTD chips,
  token colors). Reuses the retired TerminalTemplates INJURY_WIRE layout but
  never routes its sample constants. Self-hides when both feeds are empty.
- FuturesBoard (components/vyndr/FuturesBoard.tsx): real futures from
  /api/futures/:sport — championship/win-total/award markets, mono tabular
  prices + movement colored by the contract (shortening=green / drifting=amber
  / flat=dim, NEVER red; move shown ONLY when the backend supplies one).
  Carries the honest "TRACKED · NOT GRADED" label — no fabricated grades on
  futures. Self-hides when markets:[].
- ExploreHub is offseason-aware (via emptyState OFF_SEASON month check): the
  hub LEADS with futures + wire when the board is dark, COMPLEMENTS the live
  board in-season. Sport selector kept; each section self-hides independently.
- Testable pure helpers: lib/futuresMove.js (move→color, never red) +
  lib/newsFormat.js (timeAgo mono-stamp, ESPN type labels).

Contracts consumed (Wave 2A owns the proxy/service files); code self-hides on
fetch failure if a proxy isn't present yet.

Tests: tests/unit/newsWire.test.js + futuresBoard.test.js (26 new). Full suite
259 suites / 3144 green; web build exit 0. vyndrParityQA stays green
(mono data, no glitch on data surfaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 23:27:19 -04:00
parent a4b6255bed
commit 1664e1b3d5
8 changed files with 558 additions and 0 deletions
+40
View File
@@ -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 };
+53
View File
@@ -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 };